ranbval-sdk 2.2.0__tar.gz → 2.3.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.
Files changed (38) hide show
  1. {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/PKG-INFO +132 -2
  2. {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/README.md +131 -1
  3. {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/pyproject.toml +1 -1
  4. {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/__init__.py +7 -1
  5. {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/crypto/__init__.py +8 -1
  6. {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/crypto/cipher.py +4 -1
  7. {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/crypto/secret_string.py +184 -40
  8. {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/exceptions.py +16 -0
  9. {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/LICENSE +0 -0
  10. {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/_internal/__init__.py +0 -0
  11. {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/_internal/defaults.py +0 -0
  12. {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/_internal/logging.py +0 -0
  13. {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/_internal/transport.py +0 -0
  14. {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/config/__init__.py +0 -0
  15. {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/config/access.py +0 -0
  16. {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/config/declarative.py +0 -0
  17. {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/config/loader.py +0 -0
  18. {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/config/manifest.py +0 -0
  19. {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/config/reveal.py +0 -0
  20. {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/crypto/audit.py +0 -0
  21. {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/crypto/repo_policy.py +0 -0
  22. {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/integrations/__init__.py +0 -0
  23. {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/integrations/proxy.py +0 -0
  24. {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/policy/__init__.py +0 -0
  25. {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/policy/repo.py +0 -0
  26. {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/py.typed +0 -0
  27. {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/serializers/__init__.py +0 -0
  28. {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/serializers/audit.py +0 -0
  29. {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/serializers/proxy.py +0 -0
  30. {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/serializers/telemetry.py +0 -0
  31. {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/serializers/token.py +0 -0
  32. {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/telemetry/__init__.py +0 -0
  33. {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/telemetry/client.py +0 -0
  34. {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/telemetry/context.py +0 -0
  35. {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/telemetry/decorators.py +0 -0
  36. {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/telemetry/monitor.py +0 -0
  37. {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/telemetry/sampling.py +0 -0
  38. {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/telemetry/settings.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ranbval-sdk
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: Keep API secrets out of plaintext config: layered .ranbval* env files, decrypt vault tokens only when used, with built-in repo-allowlist enforcement and automatic usage telemetry to the Live Monitor—minimal deps, your own HTTP/SDK stack.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -24,7 +24,7 @@ Description-Content-Type: text/markdown
24
24
  [![Python](https://img.shields.io/pypi/pyversions/ranbval-sdk)](https://pypi.org/project/ranbval-sdk/)
25
25
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
26
26
 
27
- # Ranbval SDK `v2.2.0`
27
+ # Ranbval SDK `v2.2.1`
28
28
 
29
29
  **The Python client for Ranbval — a secret manager for API keys.** Encrypt secrets in the
30
30
  Ranbval dashboard, store the encrypted tokens in `.ranbval` files, and decrypt them only at
@@ -138,6 +138,9 @@ OPENAI_API_KEY=ranbval.4ii0a022aa.p1GOZ...ahsan
138
138
  | `safe_decrypt()` | Decrypts a vault token string → `SecretString` |
139
139
  | `decrypt_key()` | Reads an env var and decrypts it in one call |
140
140
  | `SecretString` | Wrapper that blocks all display paths — value only via `.use()` |
141
+ | `require_reveal_scope()` / `reveal_scope()` | Restrict a secret so `.use()` works only inside an approved block |
142
+ | `install_access_monitor()` | Detect & report suspicious secret access / possible exfiltration |
143
+ | `set_enforcement()` / `is_enforced()` | Toggle strict mode — extraction attempts raise `RanbvalSecurityError` (on by default) |
141
144
  | `proxy_request()` | Route an HTTP request through the Ranbval proxy (key injected server-side) |
142
145
  | `emit_telemetry()` | Record a **custom** usage event (basic usage is auto-reported on every `decrypt_key()`) |
143
146
  | `get_audit_log()` | Return the in-process audit log list |
@@ -512,6 +515,25 @@ clear_audit_log()
512
515
  assert get_audit_log() == []
513
516
  ```
514
517
 
518
+ `audit_scope()` captures just the accesses inside a `with` block (handy for tests): `with audit_scope() as accesses: ...` then inspect `accesses`. `install_access_monitor()` / `uninstall_access_monitor()` turn live suspicious-access detection on and off (see [Trusted-party controls](#trusted-party-controls-restrict--detect)).
519
+
520
+ ---
521
+
522
+ ## Exceptions
523
+
524
+ Every error derives from `RanbvalError`; each also subclasses the built-in it replaces, so existing `except ValueError` / `except KeyError` / `except PermissionError` code keeps working. Each carries a machine-readable `.code` and a `.context` dict.
525
+
526
+ | Exception | Also a | Raised when |
527
+ |---|---|---|
528
+ | `RanbvalDecryptError` | `ValueError` | wrong project secret, corrupt/expired token |
529
+ | `RanbvalConfigError` | `ValueError` | env var/secret missing, wrong section (`proxy_only`, `not_a_public_key`, `reveal_out_of_scope`) |
530
+ | `MissingKeyError` | `KeyError` | attribute/item access to an absent key |
531
+ | `RepoNotAllowedError` | `PermissionError` | git remote not in the project allowlist |
532
+ | `RepoPolicyError` | `PermissionError` | repo policy couldn't be loaded/verified |
533
+ | `ProxyError` | `RuntimeError` | the secure proxy rejected the request or was unreachable |
534
+
535
+ The `SecretProvider` protocol types anything that can `reveal(name) -> str` (e.g. `Vault`).
536
+
515
537
  ---
516
538
 
517
539
  ## Three sections: `[public]` · `[secrets]` · `[proxy]`
@@ -594,6 +616,114 @@ env.public("OPENAI_API_KEY") # -> raises (declared [proxy]) — use proxy_requ
594
616
 
595
617
  ---
596
618
 
619
+ ## Trusted-party controls: restrict & detect
620
+
621
+ For a value your app **must** decrypt locally (a DB password, a signing key) but that you don't
622
+ want an engineer to read from anywhere but one approved place. Once plaintext exists in a
623
+ process, in-process code can always reach it — so these tools **restrict** where it's revealed
624
+ and **detect** attempts, rather than promising the impossible ("hide it from your own code").
625
+
626
+ ### Reveal scopes — `.use()` only at the approved line
627
+
628
+ ```python
629
+ from ranbval_sdk import require_reveal_scope, reveal_scope, decrypt_key
630
+
631
+ require_reveal_scope("DATABASE_PASSWORD") # once, at startup
632
+
633
+ # The ONLY place its plaintext may be produced:
634
+ with reveal_scope("DATABASE_PASSWORD"):
635
+ conn = psycopg2.connect(password=decrypt_key("DATABASE_PASSWORD").use())
636
+
637
+ # Anywhere else — an engineer can't extract it:
638
+ decrypt_key("DATABASE_PASSWORD").use()
639
+ # → RanbvalConfigError: may only be revealed inside `with reveal_scope("DATABASE_PASSWORD")`
640
+ ```
641
+
642
+ `reveal_scope("NAME")` becomes an explicit, greppable marker you can enforce in CI ("this token
643
+ must appear in exactly one file"). It is thread-local — a scope open on one thread never permits
644
+ a reveal on another.
645
+
646
+ ### Enforcement — extraction attempts raise (strict by default)
647
+
648
+ As of **2.3.0**, the naive in-memory extraction vectors don't just get reported — they **raise
649
+ `RanbvalSecurityError`**, so a script trying to steal the value fails loudly instead of walking
650
+ off with it:
651
+
652
+ ```python
653
+ key = decrypt_key("OPENAI_API_KEY")
654
+ val = key.use()
655
+
656
+ client = OpenAI(api_key=key.use()) # ✅ correct — pass it straight in
657
+ f"Bearer {val}" # ✅ works (SDK header building)
658
+ "Bearer " + val # ✅ works (concatenation)
659
+
660
+ "".join(c for c in val) # ❌ RanbvalSecurityError (iteration)
661
+ val.encode() # ❌ RanbvalSecurityError (encode)
662
+ val[:] / val[0] # ❌ RanbvalSecurityError (slice / index)
663
+ str(val) / print(val) / "%s" % val # ❌ RanbvalSecurityError (str/display)
664
+ some_secret._buf # ❌ RanbvalSecurityError (buffer read)
665
+ object.__getattribute__(s, "_buf") # ❌ RanbvalSecurityError (honeypot property)
666
+ ```
667
+
668
+ > `str(val)` **raises** under enforcement (loud) instead of returning `[ranbval:secret]`; with
669
+ > `set_enforcement(False)` it masks as before. `repr(val)` always stays masked (so error
670
+ > reporters and debuggers don't crash).
671
+
672
+ If a legitimate library trips it (an AWS SigV4 signer or a DB driver that must `.encode()` the
673
+ credential), turn enforcement off process-wide:
674
+
675
+ ```python
676
+ from ranbval_sdk import set_enforcement
677
+ set_enforcement(False) # back to detect + notify (value returned, event still fires)
678
+ ```
679
+
680
+ ### Access monitor — detect suspicious access / exfiltration
681
+
682
+ With enforcement **off**, the same vectors are *detected and reported* instead of blocked (and
683
+ the access monitor always adds context — REPL use, file-write correlation — regardless):
684
+
685
+ ```python
686
+ from ranbval_sdk import install_access_monitor
687
+
688
+ install_access_monitor() # signals go to the Live Monitor
689
+ # or handle them yourself:
690
+ install_access_monitor(on_event=lambda e: log.warning("secret access", **e))
691
+ ```
692
+
693
+ It fires an event when a secret is accessed or manipulated in a way that signals extraction:
694
+
695
+ | Signal | Fires when | Enforced (raises)? |
696
+ |---|---|---|
697
+ | `secret.suspicious_access` | `.use()` from `python -c` / a REPL / a notebook (not your app) | no — reported only |
698
+ | `secret.possible_exfil` (`iteration`) | `''.join(ch for ch in key.use())` / `list(...)` / a comprehension | **yes** |
699
+ | `secret.possible_exfil` (`encode`) | `key.use().encode()` | **yes** |
700
+ | `secret.possible_exfil` (`slice`) | `val[:]` / `val[0]` / any indexing of a revealed value | **yes** |
701
+ | `secret.possible_exfil` (`buffer_read`) | `s._buf` / `s._pad` — including via `object.__getattribute__` (honeypot properties) | **yes** |
702
+ | `secret.possible_exfil` (`file_write` / `subprocess`) | a file write or subprocess right after a `.use()` | no — reported only |
703
+
704
+ Nothing legitimate breaks — an SDK never iterates or slices an API key, and f-strings build
705
+ headers through a base-`str` path that is not flagged.
706
+
707
+ **Honest limit (what still can't be blocked).** Enforcement *raises the bar* — it turns silent
708
+ theft into a loud, alerting crash, and now catches the naive `str()`/`_buf`/slice/iterate
709
+ spellings — but it does **not** make in-process extraction impossible. Two floors remain, and we
710
+ deliberately do **not** fake-guard them:
711
+
712
+ - **`str.__str__(val)`** (and other base-`str` methods: `str.__getitem__(val, ...)`,
713
+ `str.encode(val)`, and concatenation `"x" + val`) return the real value. The built-in `str`
714
+ type is immutable — CPython won't let any library override it — so these cannot be intercepted,
715
+ and **the SDK depends on them**: `OpenAI(api_key=key.use())` only works because the value *is*
716
+ a real string that libraries can format/concatenate into a request. A value the SDK can use is
717
+ a value any in-process code can read. That's the fundamental trade-off, not a missing feature.
718
+ - **`object.__getattribute__(s, "_b")`** still reads the real (XOR-masked) buffer slot. Ranbval
719
+ is open source, so anyone who reads this file finds the slot name. Renaming it again would only
720
+ move the same hole.
721
+
722
+ The one true "value never on the client" answer is the [proxy](#proxy_request) — the real key is
723
+ decrypted server-side and never returned to your process at all.
724
+
725
+ ---
726
+
597
727
  ## `.ranbval` File Format
598
728
 
599
729
  `.ranbval` files follow the same `KEY=VALUE` format as `.env` files. Lines starting with `#` are comments. Blank lines are ignored. Optional `[public]` / `[secrets]` section headers group keys (see above).
@@ -2,7 +2,7 @@
2
2
  [![Python](https://img.shields.io/pypi/pyversions/ranbval-sdk)](https://pypi.org/project/ranbval-sdk/)
3
3
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
4
4
 
5
- # Ranbval SDK `v2.2.0`
5
+ # Ranbval SDK `v2.2.1`
6
6
 
7
7
  **The Python client for Ranbval — a secret manager for API keys.** Encrypt secrets in the
8
8
  Ranbval dashboard, store the encrypted tokens in `.ranbval` files, and decrypt them only at
@@ -116,6 +116,9 @@ OPENAI_API_KEY=ranbval.4ii0a022aa.p1GOZ...ahsan
116
116
  | `safe_decrypt()` | Decrypts a vault token string → `SecretString` |
117
117
  | `decrypt_key()` | Reads an env var and decrypts it in one call |
118
118
  | `SecretString` | Wrapper that blocks all display paths — value only via `.use()` |
119
+ | `require_reveal_scope()` / `reveal_scope()` | Restrict a secret so `.use()` works only inside an approved block |
120
+ | `install_access_monitor()` | Detect & report suspicious secret access / possible exfiltration |
121
+ | `set_enforcement()` / `is_enforced()` | Toggle strict mode — extraction attempts raise `RanbvalSecurityError` (on by default) |
119
122
  | `proxy_request()` | Route an HTTP request through the Ranbval proxy (key injected server-side) |
120
123
  | `emit_telemetry()` | Record a **custom** usage event (basic usage is auto-reported on every `decrypt_key()`) |
121
124
  | `get_audit_log()` | Return the in-process audit log list |
@@ -490,6 +493,25 @@ clear_audit_log()
490
493
  assert get_audit_log() == []
491
494
  ```
492
495
 
496
+ `audit_scope()` captures just the accesses inside a `with` block (handy for tests): `with audit_scope() as accesses: ...` then inspect `accesses`. `install_access_monitor()` / `uninstall_access_monitor()` turn live suspicious-access detection on and off (see [Trusted-party controls](#trusted-party-controls-restrict--detect)).
497
+
498
+ ---
499
+
500
+ ## Exceptions
501
+
502
+ Every error derives from `RanbvalError`; each also subclasses the built-in it replaces, so existing `except ValueError` / `except KeyError` / `except PermissionError` code keeps working. Each carries a machine-readable `.code` and a `.context` dict.
503
+
504
+ | Exception | Also a | Raised when |
505
+ |---|---|---|
506
+ | `RanbvalDecryptError` | `ValueError` | wrong project secret, corrupt/expired token |
507
+ | `RanbvalConfigError` | `ValueError` | env var/secret missing, wrong section (`proxy_only`, `not_a_public_key`, `reveal_out_of_scope`) |
508
+ | `MissingKeyError` | `KeyError` | attribute/item access to an absent key |
509
+ | `RepoNotAllowedError` | `PermissionError` | git remote not in the project allowlist |
510
+ | `RepoPolicyError` | `PermissionError` | repo policy couldn't be loaded/verified |
511
+ | `ProxyError` | `RuntimeError` | the secure proxy rejected the request or was unreachable |
512
+
513
+ The `SecretProvider` protocol types anything that can `reveal(name) -> str` (e.g. `Vault`).
514
+
493
515
  ---
494
516
 
495
517
  ## Three sections: `[public]` · `[secrets]` · `[proxy]`
@@ -572,6 +594,114 @@ env.public("OPENAI_API_KEY") # -> raises (declared [proxy]) — use proxy_requ
572
594
 
573
595
  ---
574
596
 
597
+ ## Trusted-party controls: restrict & detect
598
+
599
+ For a value your app **must** decrypt locally (a DB password, a signing key) but that you don't
600
+ want an engineer to read from anywhere but one approved place. Once plaintext exists in a
601
+ process, in-process code can always reach it — so these tools **restrict** where it's revealed
602
+ and **detect** attempts, rather than promising the impossible ("hide it from your own code").
603
+
604
+ ### Reveal scopes — `.use()` only at the approved line
605
+
606
+ ```python
607
+ from ranbval_sdk import require_reveal_scope, reveal_scope, decrypt_key
608
+
609
+ require_reveal_scope("DATABASE_PASSWORD") # once, at startup
610
+
611
+ # The ONLY place its plaintext may be produced:
612
+ with reveal_scope("DATABASE_PASSWORD"):
613
+ conn = psycopg2.connect(password=decrypt_key("DATABASE_PASSWORD").use())
614
+
615
+ # Anywhere else — an engineer can't extract it:
616
+ decrypt_key("DATABASE_PASSWORD").use()
617
+ # → RanbvalConfigError: may only be revealed inside `with reveal_scope("DATABASE_PASSWORD")`
618
+ ```
619
+
620
+ `reveal_scope("NAME")` becomes an explicit, greppable marker you can enforce in CI ("this token
621
+ must appear in exactly one file"). It is thread-local — a scope open on one thread never permits
622
+ a reveal on another.
623
+
624
+ ### Enforcement — extraction attempts raise (strict by default)
625
+
626
+ As of **2.3.0**, the naive in-memory extraction vectors don't just get reported — they **raise
627
+ `RanbvalSecurityError`**, so a script trying to steal the value fails loudly instead of walking
628
+ off with it:
629
+
630
+ ```python
631
+ key = decrypt_key("OPENAI_API_KEY")
632
+ val = key.use()
633
+
634
+ client = OpenAI(api_key=key.use()) # ✅ correct — pass it straight in
635
+ f"Bearer {val}" # ✅ works (SDK header building)
636
+ "Bearer " + val # ✅ works (concatenation)
637
+
638
+ "".join(c for c in val) # ❌ RanbvalSecurityError (iteration)
639
+ val.encode() # ❌ RanbvalSecurityError (encode)
640
+ val[:] / val[0] # ❌ RanbvalSecurityError (slice / index)
641
+ str(val) / print(val) / "%s" % val # ❌ RanbvalSecurityError (str/display)
642
+ some_secret._buf # ❌ RanbvalSecurityError (buffer read)
643
+ object.__getattribute__(s, "_buf") # ❌ RanbvalSecurityError (honeypot property)
644
+ ```
645
+
646
+ > `str(val)` **raises** under enforcement (loud) instead of returning `[ranbval:secret]`; with
647
+ > `set_enforcement(False)` it masks as before. `repr(val)` always stays masked (so error
648
+ > reporters and debuggers don't crash).
649
+
650
+ If a legitimate library trips it (an AWS SigV4 signer or a DB driver that must `.encode()` the
651
+ credential), turn enforcement off process-wide:
652
+
653
+ ```python
654
+ from ranbval_sdk import set_enforcement
655
+ set_enforcement(False) # back to detect + notify (value returned, event still fires)
656
+ ```
657
+
658
+ ### Access monitor — detect suspicious access / exfiltration
659
+
660
+ With enforcement **off**, the same vectors are *detected and reported* instead of blocked (and
661
+ the access monitor always adds context — REPL use, file-write correlation — regardless):
662
+
663
+ ```python
664
+ from ranbval_sdk import install_access_monitor
665
+
666
+ install_access_monitor() # signals go to the Live Monitor
667
+ # or handle them yourself:
668
+ install_access_monitor(on_event=lambda e: log.warning("secret access", **e))
669
+ ```
670
+
671
+ It fires an event when a secret is accessed or manipulated in a way that signals extraction:
672
+
673
+ | Signal | Fires when | Enforced (raises)? |
674
+ |---|---|---|
675
+ | `secret.suspicious_access` | `.use()` from `python -c` / a REPL / a notebook (not your app) | no — reported only |
676
+ | `secret.possible_exfil` (`iteration`) | `''.join(ch for ch in key.use())` / `list(...)` / a comprehension | **yes** |
677
+ | `secret.possible_exfil` (`encode`) | `key.use().encode()` | **yes** |
678
+ | `secret.possible_exfil` (`slice`) | `val[:]` / `val[0]` / any indexing of a revealed value | **yes** |
679
+ | `secret.possible_exfil` (`buffer_read`) | `s._buf` / `s._pad` — including via `object.__getattribute__` (honeypot properties) | **yes** |
680
+ | `secret.possible_exfil` (`file_write` / `subprocess`) | a file write or subprocess right after a `.use()` | no — reported only |
681
+
682
+ Nothing legitimate breaks — an SDK never iterates or slices an API key, and f-strings build
683
+ headers through a base-`str` path that is not flagged.
684
+
685
+ **Honest limit (what still can't be blocked).** Enforcement *raises the bar* — it turns silent
686
+ theft into a loud, alerting crash, and now catches the naive `str()`/`_buf`/slice/iterate
687
+ spellings — but it does **not** make in-process extraction impossible. Two floors remain, and we
688
+ deliberately do **not** fake-guard them:
689
+
690
+ - **`str.__str__(val)`** (and other base-`str` methods: `str.__getitem__(val, ...)`,
691
+ `str.encode(val)`, and concatenation `"x" + val`) return the real value. The built-in `str`
692
+ type is immutable — CPython won't let any library override it — so these cannot be intercepted,
693
+ and **the SDK depends on them**: `OpenAI(api_key=key.use())` only works because the value *is*
694
+ a real string that libraries can format/concatenate into a request. A value the SDK can use is
695
+ a value any in-process code can read. That's the fundamental trade-off, not a missing feature.
696
+ - **`object.__getattribute__(s, "_b")`** still reads the real (XOR-masked) buffer slot. Ranbval
697
+ is open source, so anyone who reads this file finds the slot name. Renaming it again would only
698
+ move the same hole.
699
+
700
+ The one true "value never on the client" answer is the [proxy](#proxy_request) — the real key is
701
+ decrypted server-side and never returned to your process at all.
702
+
703
+ ---
704
+
575
705
  ## `.ranbval` File Format
576
706
 
577
707
  `.ranbval` files follow the same `KEY=VALUE` format as `.env` files. Lines starting with `#` are comments. Blank lines are ignored. Optional `[public]` / `[secrets]` section headers group keys (see above).
@@ -2,7 +2,7 @@
2
2
  # [tool.poetry] block below only carries build-time bits that have no PEP 621 field.
3
3
  [project]
4
4
  name = "ranbval-sdk"
5
- version = "2.2.0"
5
+ version = "2.3.0"
6
6
  description = "Keep API secrets out of plaintext config: layered .ranbval* env files, decrypt vault tokens only when used, with built-in repo-allowlist enforcement and automatic usage telemetry to the Live Monitor—minimal deps, your own HTTP/SDK stack."
7
7
  authors = [
8
8
  { name = "Ahsan Tariq" },
@@ -52,7 +52,9 @@ from ranbval_sdk.crypto import (
52
52
  clear_audit_log,
53
53
  decrypt_key,
54
54
  get_audit_log,
55
+ is_enforced,
55
56
  safe_decrypt,
57
+ set_enforcement,
56
58
  )
57
59
  from ranbval_sdk.exceptions import (
58
60
  MissingKeyError,
@@ -60,6 +62,7 @@ from ranbval_sdk.exceptions import (
60
62
  RanbvalConfigError,
61
63
  RanbvalDecryptError,
62
64
  RanbvalError,
65
+ RanbvalSecurityError,
63
66
  RepoNotAllowedError,
64
67
  RepoPolicyError,
65
68
  )
@@ -73,7 +76,7 @@ from ranbval_sdk.telemetry import (
73
76
  uninstall_access_monitor,
74
77
  )
75
78
 
76
- __version__ = "2.2.0"
79
+ __version__ = "2.3.0"
77
80
 
78
81
  __all__ = [
79
82
  # Config
@@ -102,6 +105,8 @@ __all__ = [
102
105
  "safe_decrypt",
103
106
  "decrypt_key",
104
107
  "SecretString",
108
+ "set_enforcement",
109
+ "is_enforced",
105
110
  "get_audit_log",
106
111
  "clear_audit_log",
107
112
  "audit_scope",
@@ -123,4 +128,5 @@ __all__ = [
123
128
  "RepoNotAllowedError",
124
129
  "RepoPolicyError",
125
130
  "ProxyError",
131
+ "RanbvalSecurityError",
126
132
  ]
@@ -12,7 +12,12 @@ from ranbval_sdk.crypto.audit import (
12
12
  record_access,
13
13
  )
14
14
  from ranbval_sdk.crypto.cipher import decrypt_key, derive_key, safe_decrypt
15
- from ranbval_sdk.crypto.secret_string import SecretString, install_output_guards
15
+ from ranbval_sdk.crypto.secret_string import (
16
+ SecretString,
17
+ install_output_guards,
18
+ is_enforced,
19
+ set_enforcement,
20
+ )
16
21
 
17
22
  __all__ = [
18
23
  "safe_decrypt",
@@ -20,6 +25,8 @@ __all__ = [
20
25
  "derive_key",
21
26
  "SecretString",
22
27
  "install_output_guards",
28
+ "set_enforcement",
29
+ "is_enforced",
23
30
  "get_audit_log",
24
31
  "clear_audit_log",
25
32
  "audit_scope",
@@ -61,7 +61,10 @@ def derive_key(password: str, salt_str: str) -> bytes:
61
61
  salt=salt,
62
62
  iterations=PBKDF2_ITERATIONS,
63
63
  )
64
- return kdf.derive(password.encode())
64
+ # The project secret arrives as a _ProtectedStr (from get_project_key → .use()). Call the
65
+ # base str.encode directly so this SDK-internal key derivation is not tripped by extraction
66
+ # enforcement — same deliberate-internal-read pattern the value type uses elsewhere.
67
+ return kdf.derive(str.encode(password))
65
68
 
66
69
 
67
70
  def _enforce_repo_allowlist_if_configured(client_salt: str) -> None:
@@ -27,10 +27,13 @@ as a sealed ``SecretString`` in your code, and every display path above is maske
27
27
 
28
28
  Honest limits (a security library must not over-promise)
29
29
  --------------------------------------------------------
30
- - ``.use()`` returns a **real ``str``** so third-party SDKs can build headers with it. That
31
- means the plaintext genuinely exists in the string``secret.use()[:]`` or
32
- ``print(f"{secret.use()}")`` will reveal it. That is *deliberate* bypassing, not the
33
- accidental leak this guards; anything the SDK can read to build a request, code can read too.
30
+ - ``.use()`` returns a **real ``str``** so third-party SDKs can build headers with it. Under
31
+ enforcement (the default) the naive extraction spellings raiseiteration, ``.encode()``,
32
+ ``val[:]`` / indexing, and ``_buf``/``_pad`` reads (see ``set_enforcement``). But the plaintext
33
+ genuinely exists in the string, so the **base** ``str`` methods still reach it
34
+ ``str.__str__(val)`` and ``str.__getitem__(val, ...)`` cannot be blocked (the ``str`` type is
35
+ immutable) and ``object.__getattribute__(s, "_b")`` reads the real slot. This is bar-raising,
36
+ not absolute; anything the SDK can read to build a request, determined code can read too.
34
37
  - "Zeroing" and ``mlock`` are **best-effort defence-in-depth, not guarantees.** In a managed
35
38
  runtime (CPython) the interpreter and the SDK make immutable ``str``/``bytes`` copies of the
36
39
  value that this class cannot pin or wipe. An attacker who can read your process memory
@@ -107,37 +110,52 @@ class _ProtectedStr(str):
107
110
  return str.__new__(cls, value)
108
111
 
109
112
  def __str__(self) -> str:
113
+ # Under enforcement, str()/print()/'%s' raise instead of masking — a loud failure so an
114
+ # accidental (or deliberate) str-dump is caught. With enforcement off, it masks as before.
115
+ # Honest limit: the *base* str.__str__(self) call skips this method entirely and returns
116
+ # the real value — the str type is immutable, so that path cannot be intercepted.
117
+ if _enforce:
118
+ _raise_extraction("str")
110
119
  return "[ranbval:secret]"
111
120
 
112
121
  def __repr__(self) -> str:
122
+ # repr stays masked even under enforcement: error reporters (Sentry) and debuggers repr()
123
+ # locals, and raising there would break error reporting itself.
113
124
  return "SecretString(***)"
114
125
 
115
126
  def __format__(self, spec: str) -> str:
116
- # Python's str.__format__ calls str(self) internally, hitting __str__ and
117
- # returning "[ranbval:secret]" — which breaks SDK f-string header construction.
118
- # self[:] slices the underlying str buffer, returning a plain str with the
119
- # real value without going through __str__. format() on that plain str works
120
- # correctly, so f"Bearer {key}" in SDK code builds the right Authorization header.
121
- # print(x) (which calls __str__ directly) remains masked.
122
- return format(self[:], spec)
127
+ # Python's str.__format__ calls str(self) internally, hitting __str__ and returning
128
+ # "[ranbval:secret]" — which breaks SDK f-string header construction. We reconstruct the
129
+ # real value via the *base* str.__getitem__ (NOT self[:], which now hits our blocking
130
+ # __getitem__), so f"Bearer {key}" builds the right Authorization header while external
131
+ # slicing stays blocked. print(x) (which calls __str__ directly) remains masked.
132
+ return format(str.__getitem__(self, slice(None)), spec)
133
+
134
+ def __getitem__(self, key):
135
+ # Slicing / indexing a revealed value (``val[:]``, ``val[0]``, ``val[1:5]``) reads the
136
+ # plaintext out character by character — an extraction path. Reported, and (under
137
+ # enforcement, the default) BLOCKED. The SDK's own f-string path uses the base
138
+ # str.__getitem__ (see __format__), so legitimate header building is unaffected.
139
+ # Honest limit: ``str.__getitem__(val, ...)`` on the base class still bypasses this.
140
+ _guard_reveal("slice")
141
+ return str.__getitem__(self, key)
123
142
 
124
143
  def __iter__(self):
125
144
  # Character-by-character iteration is the signature of an in-memory extraction
126
145
  # (``''.join(ch for ch in key.use())``, ``list(key.use())``, a comprehension) — a
127
- # legitimate SDK never iterates an API key. We do NOT block it (that would be
128
- # futile and breaks nothing legitimate); we REPORT it to the opt-in access monitor
129
- # and still yield the real characters, so honest use is unaffected but theft leaves
130
- # a trace. f-strings hit __format__ (which slices, not iterates), so no false alarm.
131
- _notify_reveal("iteration")
146
+ # legitimate SDK never iterates an API key. Reported to the access monitor, and (in
147
+ # enforcement mode, the default) BLOCKED with RanbvalSecurityError so the theft fails
148
+ # loudly. f-strings hit __format__ (which slices, not iterates), so no false alarm.
149
+ _guard_reveal("iteration")
132
150
  return super().__iter__()
133
151
 
134
152
  def encode(self, encoding: str = "utf-8", errors: str = "strict") -> bytes:
135
153
  # ``val.encode()`` turns the secret into raw bytes — an extraction path (and how
136
- # ``bcrypt.hashpw`` etc. take it). Report it, then return the real bytes. Note: some
137
- # signing SDKs (AWS SigV4/HMAC) and DB drivers encode the credential legitimately, so
138
- # this signal can be a false positive; it never blocks. (OpenAI-style header building
139
- # hits __format__ on a plain str, not this method, so it stays quiet.)
140
- _notify_reveal("encode")
154
+ # ``bcrypt.hashpw`` etc. take it). Reported, and (in enforcement mode, the default)
155
+ # BLOCKED. Note: some signing SDKs (AWS SigV4/HMAC) and DB drivers encode the credential
156
+ # legitimately if one trips this, call set_enforcement(False). (OpenAI-style header
157
+ # building hits __format__ on a plain str, not this method, so it stays quiet.)
158
+ _guard_reveal("encode")
141
159
  return super().encode(encoding, errors)
142
160
 
143
161
  # Serialization is a real accidental-leak path: error reporters (Sentry) pickle locals,
@@ -177,6 +195,94 @@ def _notify_reveal(method: str) -> None:
177
195
  pass
178
196
 
179
197
 
198
+ # ── Enforcement (strict by default) ───────────────────────────────────────────
199
+ # When enforcement is on, a detected extraction (iteration / encode / slice / buffer read) is
200
+ # not merely reported — it raises ``RanbvalSecurityError`` so the offending code fails loudly
201
+ # instead of silently walking off with the plaintext. Honest limit: this stops the *naive*
202
+ # vectors; the base ``str`` methods (``str.__str__(val)``, ``str.__getitem__(val, ...)``) and the
203
+ # real slot ``object.__getattribute__(s, "_b")`` still bypass it in-process and cannot be blocked.
204
+ # Only ``[proxy]`` secrets are absolute. Turn off with ``set_enforcement(False)``.
205
+ _enforce: bool = True
206
+
207
+ _EXTRACTION_MESSAGE = {
208
+ "iteration": (
209
+ "Ranbval: character-by-character iteration of a secret is blocked — this is how "
210
+ "in-memory extraction (''.join(c for c in key.use())) works. Pass the value straight "
211
+ "to your SDK/HTTP client instead. If a legitimate library needs to iterate it, call "
212
+ "set_enforcement(False); for absolute safety use a [proxy] secret."
213
+ ),
214
+ "encode": (
215
+ "Ranbval: encoding a secret to bytes is blocked (an extraction path). Pass key.use() "
216
+ "directly to the client that needs it. If a legitimate signer/driver must encode it "
217
+ "(e.g. AWS SigV4, a DB driver), call set_enforcement(False); a [proxy] secret avoids "
218
+ "the plaintext entirely."
219
+ ),
220
+ "slice": (
221
+ "Ranbval: slicing/indexing a secret (val[:], val[0]) is blocked — it reads the plaintext "
222
+ "out character by character. Pass key.use() straight to your client; f-strings still work. "
223
+ "(set_enforcement(False) to disable; a [proxy] secret is the only absolute guarantee.)"
224
+ ),
225
+ "str": (
226
+ "Ranbval: str()/print()/'%s' of a secret is blocked under enforcement (it is masked when "
227
+ "enforcement is off). Pass key.use() straight to your client; f-strings build headers fine. "
228
+ "Note: the base str.__str__(val) call CANNOT be intercepted (the str type is immutable) — "
229
+ "only a [proxy] secret keeps the value off the client entirely. (set_enforcement(False) to disable.)"
230
+ ),
231
+ "buffer_read": (
232
+ "Ranbval: reading a secret's internal buffer (_buf/_pad) is blocked — no legitimate "
233
+ "caller touches these. Use key.use() at the point of use. (set_enforcement(False) to "
234
+ "disable; a [proxy] secret is the only absolute guarantee.)"
235
+ ),
236
+ }
237
+
238
+
239
+ def set_enforcement(enabled: bool) -> None:
240
+ """Turn extraction enforcement on/off process-wide (default: on).
241
+
242
+ On → a detected extraction (iteration / encode / raw buffer read) raises
243
+ :class:`RanbvalSecurityError`.
244
+ Off → the extraction is only reported to the access monitor (detect + notify), and the
245
+ real value is returned — legacy behaviour, for when a legitimate library trips it.
246
+ """
247
+ global _enforce
248
+ _enforce = bool(enabled)
249
+
250
+
251
+ def is_enforced() -> bool:
252
+ """True when extraction attempts raise (strict mode). See :func:`set_enforcement`."""
253
+ return _enforce
254
+
255
+
256
+ def _guard_reveal(method: str) -> None:
257
+ """Report the reveal-side signal, then (in enforcement mode) raise to stop the extraction.
258
+
259
+ The notify runs first so the Live Monitor still records the attempt before the caller
260
+ crashes; the raise is what converts silent theft into a loud, alerting failure.
261
+ """
262
+ _notify_reveal(method)
263
+ if _enforce:
264
+ _raise_extraction(method)
265
+
266
+
267
+ def _raise_extraction(method: str) -> None:
268
+ """Raise the extraction error (no notify). Used by paths — like ``str()`` — that are masked
269
+ (and frequent) when enforcement is off, so we must not flood the monitor with events."""
270
+ from ranbval_sdk.exceptions import RanbvalSecurityError
271
+
272
+ raise RanbvalSecurityError(
273
+ _EXTRACTION_MESSAGE.get(method, f"Ranbval: blocked secret extraction via {method}."),
274
+ code="secret_extraction_blocked",
275
+ method=method,
276
+ )
277
+
278
+
279
+ def _reconstruct(buf: bytearray, pad: bytearray) -> bytes:
280
+ """XOR-unmask the stored buffer back to plaintext bytes. Module-level (not a method) so a
281
+ caller cannot reveal a secret via ``s.<method>()`` — the SDK reads the slots with
282
+ ``object.__getattribute__`` and calls this internally."""
283
+ return bytes(b ^ p for b, p in zip(buf, pad, strict=True))
284
+
285
+
180
286
  # Set by :mod:`ranbval_sdk.config.reveal`. Called with the secret's label inside ``.use()``;
181
287
  # it raises if that secret is restricted to a reveal scope and we are not inside one.
182
288
  _reveal_gate: object = None
@@ -243,14 +349,20 @@ def install_output_guards() -> None:
243
349
  class SecretString:
244
350
  """Holds a decrypted secret in memory, XOR-masked with a per-instance random pad.
245
351
 
246
- The plaintext is never stored as-is: ``_buf`` holds ``plaintext XOR _pad``, so reading the
247
- internal buffer directly (``object.__getattribute__(s, "_buf")``) yields only garbage. To
248
- reconstruct the value you must read *both* slots and know the scheme — which pushes any
249
- reader back through :meth:`use` (the gated, audited access point). This is bar-raising, not
250
- absolute (a determined insider can read both slots); it closes the naive one-slot bypass.
352
+ The plaintext is never stored as-is: the real bytes live in the private slots ``_b`` (=
353
+ ``plaintext XOR _p``) and ``_p`` (the pad), so even reading one slot yields only garbage.
354
+
355
+ ``_buf`` and ``_pad`` are **honeypot properties**: any read of them including the
356
+ ``object.__getattribute__(s, "_buf")`` form that used to bypass the class fires the
357
+ reveal guard and (under enforcement) raises. This closes the known buffer-read PoC.
358
+
359
+ **Honest limit.** The real slots ``_b`` / ``_p`` still exist and can be read with
360
+ ``object.__getattribute__(s, "_b")`` by anyone who reads this (open-source) file — it is
361
+ bar-raising against naive/automated extraction, not an absolute guarantee. The only value
362
+ that never exists in the client process is a ``[proxy]`` secret.
251
363
  """
252
364
 
253
- __slots__ = ("_buf", "_pad", "_label", "_wiped")
365
+ __slots__ = ("_b", "_p", "_label", "_wiped")
254
366
 
255
367
  def __init_subclass__(cls, **kwargs: object) -> None:
256
368
  raise TypeError("SecretString cannot be subclassed")
@@ -261,22 +373,32 @@ class SecretString:
261
373
  buf = bytearray(b ^ p for b, p in zip(raw, pad, strict=True)) # plaintext XOR pad
262
374
  _try_mlock(buf) # pin to RAM — no swap to disk
263
375
  _try_mlock(pad)
264
- object.__setattr__(self, "_buf", buf)
265
- object.__setattr__(self, "_pad", pad)
376
+ object.__setattr__(self, "_b", buf)
377
+ object.__setattr__(self, "_p", pad)
266
378
  object.__setattr__(self, "_label", label)
267
379
  object.__setattr__(self, "_wiped", False)
268
380
 
269
- def _plaintext_bytes(self) -> bytes:
270
- """Reconstruct the plaintext bytes from the masked buffer (transientfor use/eq/hash)."""
271
- buf = object.__getattribute__(self, "_buf")
272
- pad = object.__getattribute__(self, "_pad")
273
- return bytes(b ^ p for b, p in zip(buf, pad, strict=True))
381
+ # ── Buffer honeypots ───────────────────────────────────────────────────
382
+ # Reading ``s._buf`` / ``s._pad`` is a reveal-gate / monitor bypass a normal caller never
383
+ # touches these. Exposing them as *properties* (data descriptors) means the guard fires even
384
+ # for ``object.__getattribute__(s, "_buf")``, which the old __slots__ layout let through.
385
+ # The SDK's own internals read the real slots ``_b`` / ``_p`` directly, so they don't trip it.
386
+
387
+ @property
388
+ def _buf(self) -> bytearray:
389
+ _guard_reveal("buffer_read")
390
+ return object.__getattribute__(self, "_b")
391
+
392
+ @property
393
+ def _pad(self) -> bytearray:
394
+ _guard_reveal("buffer_read")
395
+ return object.__getattribute__(self, "_p")
274
396
 
275
397
  # ── Memory wipe ────────────────────────────────────────────────────────
276
398
 
277
399
  def wipe(self) -> None:
278
400
  """Zero the secret bytes in memory and unpin from RAM. After this, use() raises RuntimeError."""
279
- for name in ("_buf", "_pad"):
401
+ for name in ("_b", "_p"):
280
402
  b = object.__getattribute__(self, name)
281
403
  _try_munlock(b) # unpin before zeroing
282
404
  b[:] = b"\x00" * len(b)
@@ -293,6 +415,9 @@ class SecretString:
293
415
  # ── All display paths are blocked ──────────────────────────────────────
294
416
 
295
417
  def __str__(self) -> str:
418
+ # Under enforcement, str()/print() of the sealed wrapper raises (loud); otherwise masks.
419
+ if _enforce:
420
+ _raise_extraction("str")
296
421
  return "[ranbval:secret]"
297
422
 
298
423
  def __repr__(self) -> str:
@@ -310,12 +435,27 @@ class SecretString:
310
435
  other, "_wiped"
311
436
  ):
312
437
  return False
313
- # Deobfuscate both (each has its own pad) and compare in constant time.
314
- return hmac.compare_digest(self._plaintext_bytes(), other._plaintext_bytes())
438
+ # Deobfuscate both (each has its own pad) and compare in constant time. Read the
439
+ # slots via object.__getattribute__ so we don't trip our own buffer-read monitor.
440
+ return hmac.compare_digest(
441
+ _reconstruct(
442
+ object.__getattribute__(self, "_b"),
443
+ object.__getattribute__(self, "_p"),
444
+ ),
445
+ _reconstruct(
446
+ object.__getattribute__(other, "_b"),
447
+ object.__getattribute__(other, "_p"),
448
+ ),
449
+ )
315
450
  return NotImplemented
316
451
 
317
452
  def __hash__(self) -> int:
318
- return hash(self._plaintext_bytes())
453
+ return hash(
454
+ _reconstruct(
455
+ object.__getattribute__(self, "_b"),
456
+ object.__getattribute__(self, "_p"),
457
+ )
458
+ )
319
459
 
320
460
  # Block attribute setting from outside
321
461
  def __setattr__(self, _name: str, _value: object) -> None:
@@ -367,7 +507,11 @@ class SecretString:
367
507
  from ranbval_sdk.crypto.audit import record_access
368
508
 
369
509
  record_access(label)
370
- return _ProtectedStr(self._plaintext_bytes().decode("utf-8"))
510
+ plaintext = _reconstruct(
511
+ object.__getattribute__(self, "_b"),
512
+ object.__getattribute__(self, "_p"),
513
+ )
514
+ return _ProtectedStr(plaintext.decode("utf-8"))
371
515
 
372
516
  def __del__(self) -> None:
373
517
  try:
@@ -378,7 +522,7 @@ class SecretString:
378
522
 
379
523
  def __len__(self) -> int:
380
524
  """Length of the secret in bytes (safe — does not reveal content)."""
381
- return len(object.__getattribute__(self, "_buf"))
525
+ return len(object.__getattribute__(self, "_b"))
382
526
 
383
527
  @property
384
528
  def label(self) -> str:
@@ -98,6 +98,21 @@ class ProxyError(RanbvalError, RuntimeError):
98
98
  default_code = "proxy_error"
99
99
 
100
100
 
101
+ class RanbvalSecurityError(RanbvalError, PermissionError):
102
+ """A revealed secret was manipulated in a way that signals in-memory extraction
103
+ (char-by-char iteration, ``.encode()`` to bytes, or a direct read of the internal
104
+ buffer) while enforcement is on. Raised to turn silent theft into a loud failure.
105
+
106
+ This is a **naive-attacker deterrent, not a guarantee** — once ``.use()`` returns a real
107
+ ``str``, the base ``str`` methods (``str.__str__(val)``, ``str.__getitem__(val, ...)``) and
108
+ the real buffer slot (``object.__getattribute__(s, "_b")``) still reach the plaintext
109
+ in-process and cannot be blocked. Only ``[proxy]`` secrets (plaintext never enters the client)
110
+ are absolute. Disable with ``set_enforcement(False)`` if a legitimate library trips it.
111
+ """
112
+
113
+ default_code = "secret_extraction_blocked"
114
+
115
+
101
116
  __all__ = [
102
117
  "RanbvalError",
103
118
  "RanbvalDecryptError",
@@ -106,4 +121,5 @@ __all__ = [
106
121
  "RepoNotAllowedError",
107
122
  "RepoPolicyError",
108
123
  "ProxyError",
124
+ "RanbvalSecurityError",
109
125
  ]
File without changes