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.
- {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/PKG-INFO +132 -2
- {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/README.md +131 -1
- {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/pyproject.toml +1 -1
- {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/__init__.py +7 -1
- {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/crypto/__init__.py +8 -1
- {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/crypto/cipher.py +4 -1
- {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/crypto/secret_string.py +184 -40
- {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/exceptions.py +16 -0
- {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/LICENSE +0 -0
- {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/_internal/__init__.py +0 -0
- {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/_internal/defaults.py +0 -0
- {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/_internal/logging.py +0 -0
- {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/_internal/transport.py +0 -0
- {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/config/__init__.py +0 -0
- {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/config/access.py +0 -0
- {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/config/declarative.py +0 -0
- {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/config/loader.py +0 -0
- {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/config/manifest.py +0 -0
- {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/config/reveal.py +0 -0
- {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/crypto/audit.py +0 -0
- {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/crypto/repo_policy.py +0 -0
- {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/integrations/__init__.py +0 -0
- {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/integrations/proxy.py +0 -0
- {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/policy/__init__.py +0 -0
- {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/policy/repo.py +0 -0
- {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/py.typed +0 -0
- {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/serializers/__init__.py +0 -0
- {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/serializers/audit.py +0 -0
- {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/serializers/proxy.py +0 -0
- {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/serializers/telemetry.py +0 -0
- {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/serializers/token.py +0 -0
- {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/telemetry/__init__.py +0 -0
- {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/telemetry/client.py +0 -0
- {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/telemetry/context.py +0 -0
- {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/telemetry/decorators.py +0 -0
- {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/telemetry/monitor.py +0 -0
- {ranbval_sdk-2.2.0 → ranbval_sdk-2.3.0}/src/ranbval_sdk/telemetry/sampling.py +0 -0
- {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.
|
|
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
|
[](https://pypi.org/project/ranbval-sdk/)
|
|
25
25
|
[](LICENSE)
|
|
26
26
|
|
|
27
|
-
# Ranbval SDK `v2.2.
|
|
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
|
[](https://pypi.org/project/ranbval-sdk/)
|
|
3
3
|
[](LICENSE)
|
|
4
4
|
|
|
5
|
-
# Ranbval SDK `v2.2.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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.
|
|
31
|
-
|
|
32
|
-
``
|
|
33
|
-
|
|
30
|
+
- ``.use()`` returns a **real ``str``** so third-party SDKs can build headers with it. Under
|
|
31
|
+
enforcement (the default) the naive extraction spellings raise — iteration, ``.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
|
-
#
|
|
118
|
-
#
|
|
119
|
-
#
|
|
120
|
-
#
|
|
121
|
-
|
|
122
|
-
|
|
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.
|
|
128
|
-
#
|
|
129
|
-
#
|
|
130
|
-
|
|
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).
|
|
137
|
-
# signing SDKs (AWS SigV4/HMAC) and DB drivers encode the credential
|
|
138
|
-
#
|
|
139
|
-
# hits __format__ on a plain str, not this method, so it stays quiet.)
|
|
140
|
-
|
|
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:
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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__ = ("
|
|
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, "
|
|
265
|
-
object.__setattr__(self, "
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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 ("
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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, "
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|