opencode-swap 0.4.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.
- opencode_swap/__init__.py +3 -0
- opencode_swap/__main__.py +4 -0
- opencode_swap/atomic.py +95 -0
- opencode_swap/backup.py +128 -0
- opencode_swap/cli.py +799 -0
- opencode_swap/exceptions.py +60 -0
- opencode_swap/locking.py +70 -0
- opencode_swap/macos_keychain.py +136 -0
- opencode_swap/models.py +190 -0
- opencode_swap/oauth_jwt.py +66 -0
- opencode_swap/oauth_refresh.py +156 -0
- opencode_swap/opencode_auth.py +54 -0
- opencode_swap/paths.py +83 -0
- opencode_swap/process_detection.py +29 -0
- opencode_swap/providers/__init__.py +26 -0
- opencode_swap/providers/api.py +50 -0
- opencode_swap/providers/base.py +91 -0
- opencode_swap/providers/common.py +144 -0
- opencode_swap/providers/github_copilot.py +46 -0
- opencode_swap/providers/openai.py +186 -0
- opencode_swap/providers/poe.py +64 -0
- opencode_swap/providers/xai.py +72 -0
- opencode_swap/providers/zai.py +31 -0
- opencode_swap/sealed.py +83 -0
- opencode_swap/store.py +551 -0
- opencode_swap/switcher.py +952 -0
- opencode_swap/transfer.py +149 -0
- opencode_swap/usage.py +304 -0
- opencode_swap-0.4.0.dist-info/METADATA +307 -0
- opencode_swap-0.4.0.dist-info/RECORD +33 -0
- opencode_swap-0.4.0.dist-info/WHEEL +4 -0
- opencode_swap-0.4.0.dist-info/entry_points.txt +3 -0
- opencode_swap-0.4.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""OpenAI provider: reads/writes the ``"openai"`` key of OpenCode's auth.json.
|
|
2
|
+
|
|
3
|
+
Record shapes verified against OpenCode source
|
|
4
|
+
(packages/opencode/src/auth/index.ts:14-33, the Oauth/Api/WellKnown union):
|
|
5
|
+
|
|
6
|
+
oauth: {type:"oauth", refresh:str, access:str, expires:int,
|
|
7
|
+
accountId?:str, enterpriseUrl?:str}
|
|
8
|
+
api: {type:"api", key:str, metadata?:dict}
|
|
9
|
+
wellknown: {type:"wellknown", key:str, token:str}
|
|
10
|
+
|
|
11
|
+
Identity derivation mirrors opencode-balancer's authIdentityKey
|
|
12
|
+
(src/core/pending.ts:116-123): prefer the stable account id, fall back to
|
|
13
|
+
the refresh token itself so two records for the same account are still
|
|
14
|
+
recognized as the same identity even before an accountId claim is known.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import math
|
|
20
|
+
import time
|
|
21
|
+
|
|
22
|
+
from opencode_swap import oauth_refresh, usage
|
|
23
|
+
from opencode_swap.exceptions import SchemaError
|
|
24
|
+
from opencode_swap.models import AccountDesc, AuthRecord, JsonObject, Validity
|
|
25
|
+
from opencode_swap.oauth_jwt import decode_claims, extract_account_id, extract_email
|
|
26
|
+
from opencode_swap.providers.common import credential_values, is_json_number, key_account_hint, published_raw, require_expiry
|
|
27
|
+
|
|
28
|
+
PROVIDER_ID = "openai"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _require_str(raw: JsonObject, field: str, entry_type: str) -> str:
|
|
32
|
+
value = raw.get(field)
|
|
33
|
+
if not isinstance(value, str) or not value:
|
|
34
|
+
raise SchemaError(f"openai {entry_type} entry missing/invalid field {field!r}")
|
|
35
|
+
return value
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _optional_str(raw: JsonObject, field: str, entry_type: str) -> str | None:
|
|
39
|
+
if field not in raw:
|
|
40
|
+
return None
|
|
41
|
+
value = raw[field]
|
|
42
|
+
if not isinstance(value, str):
|
|
43
|
+
raise SchemaError(f"openai {entry_type} entry has invalid field {field!r}")
|
|
44
|
+
return value
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _json_value(value: object) -> bool:
|
|
48
|
+
if value is None or isinstance(value, (str, bool)):
|
|
49
|
+
return True
|
|
50
|
+
if is_json_number(value):
|
|
51
|
+
return True
|
|
52
|
+
if isinstance(value, list):
|
|
53
|
+
return all(_json_value(item) for item in value)
|
|
54
|
+
if isinstance(value, dict):
|
|
55
|
+
return all(isinstance(key, str) and _json_value(item) for key, item in value.items())
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _safe_display(value: object, record: JsonObject) -> str | None:
|
|
60
|
+
if not isinstance(value, str) or not value:
|
|
61
|
+
return None
|
|
62
|
+
if any(ord(char) < 32 or 127 <= ord(char) < 160 for char in value):
|
|
63
|
+
return None
|
|
64
|
+
if any(
|
|
65
|
+
secret and secret in value for secret in (record.get(field) for field in ("refresh", "access", "key", "token")) if isinstance(secret, str)
|
|
66
|
+
):
|
|
67
|
+
return None
|
|
68
|
+
return value
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class OpenAiProvider:
|
|
72
|
+
id = PROVIDER_ID
|
|
73
|
+
usage_record_types = frozenset({"oauth"})
|
|
74
|
+
|
|
75
|
+
def extract(self, auth: JsonObject) -> AuthRecord | None:
|
|
76
|
+
raw = auth.get(PROVIDER_ID)
|
|
77
|
+
if raw is None:
|
|
78
|
+
return None
|
|
79
|
+
if not isinstance(raw, dict) or "type" not in raw:
|
|
80
|
+
raise SchemaError("openai entry is not a recognizable auth record")
|
|
81
|
+
if not _json_value(raw):
|
|
82
|
+
raise SchemaError("openai entry contains values not representable as JSON")
|
|
83
|
+
|
|
84
|
+
entry_type = raw["type"]
|
|
85
|
+
if entry_type == "oauth":
|
|
86
|
+
_require_str(raw, "refresh", "oauth")
|
|
87
|
+
_require_str(raw, "access", "oauth")
|
|
88
|
+
require_expiry(raw, PROVIDER_ID)
|
|
89
|
+
_optional_str(raw, "accountId", "oauth")
|
|
90
|
+
_optional_str(raw, "enterpriseUrl", "oauth")
|
|
91
|
+
elif entry_type == "api":
|
|
92
|
+
_require_str(raw, "key", "api")
|
|
93
|
+
metadata = raw.get("metadata")
|
|
94
|
+
if "metadata" in raw and (not isinstance(metadata, dict) or not _json_value(metadata)):
|
|
95
|
+
raise SchemaError("openai api entry has invalid field 'metadata'")
|
|
96
|
+
elif entry_type == "wellknown":
|
|
97
|
+
_require_str(raw, "key", "wellknown")
|
|
98
|
+
_require_str(raw, "token", "wellknown")
|
|
99
|
+
else:
|
|
100
|
+
raise SchemaError("unknown openai auth type")
|
|
101
|
+
|
|
102
|
+
return AuthRecord(type=entry_type, raw=dict(raw))
|
|
103
|
+
|
|
104
|
+
def splice(self, auth: JsonObject, record: AuthRecord) -> JsonObject:
|
|
105
|
+
new_auth = dict(auth)
|
|
106
|
+
new_auth[PROVIDER_ID] = published_raw(record.raw)
|
|
107
|
+
return new_auth
|
|
108
|
+
|
|
109
|
+
def identity(self, record: AuthRecord) -> str:
|
|
110
|
+
if record.type == "oauth":
|
|
111
|
+
account_id = record.raw.get("accountId")
|
|
112
|
+
if not isinstance(account_id, str) or not account_id:
|
|
113
|
+
access = record.raw.get("access")
|
|
114
|
+
claims = decode_claims(access if isinstance(access, str) else "")
|
|
115
|
+
account_id = extract_account_id(claims)
|
|
116
|
+
if account_id:
|
|
117
|
+
return f"oauth-account\0{account_id}"
|
|
118
|
+
refresh = record.raw.get("refresh")
|
|
119
|
+
return f"oauth-refresh\0{refresh if isinstance(refresh, str) else ''}"
|
|
120
|
+
if record.type == "api":
|
|
121
|
+
key = record.raw.get("key")
|
|
122
|
+
return f"api-key\0{key if isinstance(key, str) else ''}"
|
|
123
|
+
if record.type == "wellknown":
|
|
124
|
+
key = record.raw.get("key")
|
|
125
|
+
token = record.raw.get("token")
|
|
126
|
+
return f"wellknown\0{key if isinstance(key, str) else ''}\0{token if isinstance(token, str) else ''}"
|
|
127
|
+
raise SchemaError("unknown openai auth type")
|
|
128
|
+
|
|
129
|
+
def identity_is_stable(self, record: AuthRecord) -> bool:
|
|
130
|
+
if record.type != "oauth":
|
|
131
|
+
return True
|
|
132
|
+
return self.identity(record).startswith("oauth-account\0")
|
|
133
|
+
|
|
134
|
+
def credential_values(self, record: AuthRecord) -> set[str]:
|
|
135
|
+
return credential_values(record)
|
|
136
|
+
|
|
137
|
+
def describe(self, record: AuthRecord) -> AccountDesc:
|
|
138
|
+
if record.type == "oauth":
|
|
139
|
+
access = record.raw.get("access")
|
|
140
|
+
claims = decode_claims(access if isinstance(access, str) else "")
|
|
141
|
+
account_id = record.raw.get("accountId")
|
|
142
|
+
expires = record.raw.get("expires")
|
|
143
|
+
return AccountDesc(
|
|
144
|
+
type="oauth",
|
|
145
|
+
email=_safe_display(extract_email(claims), record.raw),
|
|
146
|
+
account_id=_safe_display(account_id if isinstance(account_id, str) else extract_account_id(claims), record.raw),
|
|
147
|
+
expires=expires if isinstance(expires, (int, float)) else None,
|
|
148
|
+
)
|
|
149
|
+
if record.type == "api":
|
|
150
|
+
return AccountDesc(type="api", email=None, account_id=key_account_hint(record), expires=None)
|
|
151
|
+
return AccountDesc(type="wellknown", email=None, account_id=None, expires=None)
|
|
152
|
+
|
|
153
|
+
def validate(self, record: AuthRecord) -> Validity:
|
|
154
|
+
if record.type == "oauth":
|
|
155
|
+
expires = record.raw.get("expires")
|
|
156
|
+
if isinstance(expires, bool) or not isinstance(expires, (int, float)) or not math.isfinite(expires):
|
|
157
|
+
return Validity.INVALID
|
|
158
|
+
if expires < time.time() * 1000:
|
|
159
|
+
return Validity.EXPIRED
|
|
160
|
+
return Validity.OK
|
|
161
|
+
if record.type in ("api", "wellknown"):
|
|
162
|
+
return Validity.OK
|
|
163
|
+
return Validity.INVALID
|
|
164
|
+
|
|
165
|
+
def refresh(self, record: AuthRecord) -> AuthRecord | None:
|
|
166
|
+
if record.type != "oauth":
|
|
167
|
+
return None
|
|
168
|
+
refresh_token = record.raw.get("refresh")
|
|
169
|
+
if not isinstance(refresh_token, str) or not refresh_token:
|
|
170
|
+
raise SchemaError("openai oauth entry missing/invalid field 'refresh'")
|
|
171
|
+
tokens = oauth_refresh.refresh_openai_oauth(refresh_token, now_ms=time.time() * 1000)
|
|
172
|
+
new_raw = dict(record.raw)
|
|
173
|
+
new_raw["access"] = tokens.access
|
|
174
|
+
new_raw["refresh"] = tokens.refresh
|
|
175
|
+
new_raw["expires"] = tokens.expires
|
|
176
|
+
account_id = tokens.account_id or record.raw.get("accountId")
|
|
177
|
+
if account_id:
|
|
178
|
+
new_raw["accountId"] = account_id
|
|
179
|
+
return AuthRecord(type="oauth", raw=new_raw)
|
|
180
|
+
|
|
181
|
+
def fetch_usage(self, record: AuthRecord) -> usage.UsageSnapshot | None:
|
|
182
|
+
access = record.raw.get("access")
|
|
183
|
+
if not isinstance(access, str):
|
|
184
|
+
return None
|
|
185
|
+
account_id = record.raw.get("accountId")
|
|
186
|
+
return usage.fetch_openai_oauth_usage(access, account_id if isinstance(account_id, str) else None)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Poe OAuth/API credentials; OAuth yields a non-rotating API key."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from opencode_swap.exceptions import SchemaError
|
|
8
|
+
from opencode_swap.models import AccountDesc, AuthRecord, JsonObject, Validity
|
|
9
|
+
from opencode_swap.providers.common import (
|
|
10
|
+
credential_values,
|
|
11
|
+
extract_raw,
|
|
12
|
+
is_json_number,
|
|
13
|
+
key_account_hint,
|
|
14
|
+
published_raw,
|
|
15
|
+
validate_api,
|
|
16
|
+
validate_oauth,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PoeProvider:
|
|
21
|
+
id = "poe"
|
|
22
|
+
usage_record_types: frozenset[str] = frozenset() # no known usage endpoint
|
|
23
|
+
|
|
24
|
+
def extract(self, auth: JsonObject) -> AuthRecord | None:
|
|
25
|
+
raw = extract_raw(auth, self.id)
|
|
26
|
+
if raw is None:
|
|
27
|
+
return None
|
|
28
|
+
if raw.get("type") == "api":
|
|
29
|
+
return validate_api(raw, self.id)
|
|
30
|
+
if raw.get("type") == "oauth":
|
|
31
|
+
return validate_oauth(raw, self.id)
|
|
32
|
+
raise SchemaError("poe auth type is not supported by opencode-swap")
|
|
33
|
+
|
|
34
|
+
def splice(self, auth: JsonObject, record: AuthRecord) -> JsonObject:
|
|
35
|
+
return {**auth, self.id: published_raw(record.raw)}
|
|
36
|
+
|
|
37
|
+
def identity(self, record: AuthRecord) -> str:
|
|
38
|
+
field = "key" if record.type == "api" else "access"
|
|
39
|
+
token = record.raw.get(field)
|
|
40
|
+
return f"{record.type}\0{token if isinstance(token, str) else ''}"
|
|
41
|
+
|
|
42
|
+
def identity_is_stable(self, record: AuthRecord) -> bool:
|
|
43
|
+
return True
|
|
44
|
+
|
|
45
|
+
def credential_values(self, record: AuthRecord) -> set[str]:
|
|
46
|
+
return credential_values(record)
|
|
47
|
+
|
|
48
|
+
def describe(self, record: AuthRecord) -> AccountDesc:
|
|
49
|
+
expires = record.raw.get("expires")
|
|
50
|
+
return AccountDesc(type=record.type, email=None, account_id=key_account_hint(record), expires=expires if is_json_number(expires) else None)
|
|
51
|
+
|
|
52
|
+
def validate(self, record: AuthRecord) -> Validity:
|
|
53
|
+
if record.type == "api":
|
|
54
|
+
return Validity.OK
|
|
55
|
+
expires = record.raw.get("expires")
|
|
56
|
+
if not is_json_number(expires):
|
|
57
|
+
return Validity.INVALID
|
|
58
|
+
return Validity.EXPIRED if expires < time.time() * 1000 else Validity.OK
|
|
59
|
+
|
|
60
|
+
def refresh(self, record: AuthRecord) -> AuthRecord | None:
|
|
61
|
+
return None # no verified standalone refresh flow for this provider yet
|
|
62
|
+
|
|
63
|
+
def fetch_usage(self, record: AuthRecord) -> None:
|
|
64
|
+
return None # unreachable: usage_record_types is empty
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""xAI API/OAuth handling with guarded stable JWT subject identity."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from opencode_swap.exceptions import SchemaError
|
|
8
|
+
from opencode_swap.models import AccountDesc, AuthRecord, JsonObject, Validity
|
|
9
|
+
from opencode_swap.oauth_jwt import decode_claims
|
|
10
|
+
from opencode_swap.providers.common import (
|
|
11
|
+
credential_values,
|
|
12
|
+
extract_raw,
|
|
13
|
+
is_json_number,
|
|
14
|
+
key_account_hint,
|
|
15
|
+
published_raw,
|
|
16
|
+
validate_api,
|
|
17
|
+
validate_oauth,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class XaiProvider:
|
|
22
|
+
id = "xai"
|
|
23
|
+
usage_record_types: frozenset[str] = frozenset() # no known usage endpoint
|
|
24
|
+
|
|
25
|
+
def extract(self, auth: JsonObject) -> AuthRecord | None:
|
|
26
|
+
raw = extract_raw(auth, self.id)
|
|
27
|
+
if raw is None:
|
|
28
|
+
return None
|
|
29
|
+
if raw.get("type") == "api":
|
|
30
|
+
return validate_api(raw, self.id)
|
|
31
|
+
if raw.get("type") == "oauth":
|
|
32
|
+
return validate_oauth(raw, self.id)
|
|
33
|
+
raise SchemaError("xai auth type is not supported by opencode-swap")
|
|
34
|
+
|
|
35
|
+
def splice(self, auth: JsonObject, record: AuthRecord) -> JsonObject:
|
|
36
|
+
return {**auth, self.id: published_raw(record.raw)}
|
|
37
|
+
|
|
38
|
+
def identity(self, record: AuthRecord) -> str:
|
|
39
|
+
if record.type == "api":
|
|
40
|
+
key = record.raw.get("key")
|
|
41
|
+
return f"api-key\0{key if isinstance(key, str) else ''}"
|
|
42
|
+
access = record.raw.get("access")
|
|
43
|
+
claims = decode_claims(access if isinstance(access, str) else "")
|
|
44
|
+
subject = claims.get("sub")
|
|
45
|
+
issuer = claims.get("iss", "")
|
|
46
|
+
if not isinstance(subject, str) or not subject or not isinstance(issuer, str) or not issuer:
|
|
47
|
+
raise SchemaError("xai oauth access token has no stable JWT subject; refusing unsafe account switching")
|
|
48
|
+
return f"oauth-subject\0{issuer}\0{subject}"
|
|
49
|
+
|
|
50
|
+
def identity_is_stable(self, record: AuthRecord) -> bool:
|
|
51
|
+
return True
|
|
52
|
+
|
|
53
|
+
def credential_values(self, record: AuthRecord) -> set[str]:
|
|
54
|
+
return credential_values(record)
|
|
55
|
+
|
|
56
|
+
def describe(self, record: AuthRecord) -> AccountDesc:
|
|
57
|
+
expires = record.raw.get("expires")
|
|
58
|
+
return AccountDesc(type=record.type, email=None, account_id=key_account_hint(record), expires=expires if is_json_number(expires) else None)
|
|
59
|
+
|
|
60
|
+
def validate(self, record: AuthRecord) -> Validity:
|
|
61
|
+
if record.type == "api":
|
|
62
|
+
return Validity.OK
|
|
63
|
+
expires = record.raw.get("expires")
|
|
64
|
+
if not is_json_number(expires):
|
|
65
|
+
return Validity.INVALID
|
|
66
|
+
return Validity.EXPIRED if expires < time.time() * 1000 else Validity.OK
|
|
67
|
+
|
|
68
|
+
def refresh(self, record: AuthRecord) -> AuthRecord | None:
|
|
69
|
+
return None # no verified standalone refresh flow for this provider yet
|
|
70
|
+
|
|
71
|
+
def fetch_usage(self, record: AuthRecord) -> None:
|
|
72
|
+
return None # unreachable: usage_record_types is empty
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Z.AI GLM Coding Plan: a canonical API-key record plus a live usage lookup.
|
|
2
|
+
|
|
3
|
+
Record handling is exactly the generic API path (`ApiProvider`) -- z.ai stores
|
|
4
|
+
`{"type":"api","key":"..."}` like any other static-key provider. The only
|
|
5
|
+
reason this provider exists as its own class is the GLM Coding Plan quota
|
|
6
|
+
endpoint (see `usage.fetch_zai_usage`), which is keyed to the same API key.
|
|
7
|
+
|
|
8
|
+
Only `zai-coding-plan` is registered, not a bare `zai`: the quota endpoint is
|
|
9
|
+
coding-plan-specific, and a pay-as-you-go `zai` key would only ever report an
|
|
10
|
+
inactive plan.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from opencode_swap import usage
|
|
16
|
+
from opencode_swap.models import AuthRecord
|
|
17
|
+
from opencode_swap.providers.api import ApiProvider
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ZaiProvider(ApiProvider):
|
|
21
|
+
id = "zai-coding-plan"
|
|
22
|
+
usage_record_types = frozenset({"api"})
|
|
23
|
+
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
super().__init__(self.id)
|
|
26
|
+
|
|
27
|
+
def fetch_usage(self, record: AuthRecord) -> usage.UsageSnapshot | None:
|
|
28
|
+
key = record.raw.get("key")
|
|
29
|
+
if not isinstance(key, str) or not key:
|
|
30
|
+
return None
|
|
31
|
+
return usage.fetch_zai_usage(key)
|
opencode_swap/sealed.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""AES-256-GCM envelope format for the macOS Keychain backend.
|
|
2
|
+
|
|
3
|
+
Why this exists: a single OpenAI OAuth record is ~2KB, and hex-encoded for
|
|
4
|
+
``security -i`` that's over the 4095-char stdin line limit (see
|
|
5
|
+
macos_keychain.py) — so the raw credential can never go into a Keychain
|
|
6
|
+
item directly. Instead the Keychain holds a small (32-byte, 64 hex chars)
|
|
7
|
+
per-account data key, and the credential itself is AES-256-GCM ciphertext
|
|
8
|
+
in the existing atomic 0600 file store. This keeps every operation to one
|
|
9
|
+
``security`` spawn regardless of credential size, with no size classes.
|
|
10
|
+
|
|
11
|
+
pycryptodomex is not a new dependency: pyzipper (already required for
|
|
12
|
+
transfer.py's export/import archives) depends on it, and this module
|
|
13
|
+
formalizes that as a direct dependency.
|
|
14
|
+
|
|
15
|
+
Blob layout: b"OCS3" | nonce(12) | tag(16) | ciphertext.
|
|
16
|
+
AAD is the caller's key_id (e.g. "openai:work"), so a blob copied or
|
|
17
|
+
renamed onto a different account fails to decrypt instead of silently
|
|
18
|
+
producing garbage under the wrong identity.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from Cryptodome.Cipher import AES
|
|
24
|
+
from Cryptodome.Random import get_random_bytes
|
|
25
|
+
|
|
26
|
+
from opencode_swap.exceptions import SecretStoreError
|
|
27
|
+
|
|
28
|
+
_MAGIC = b"OCS3"
|
|
29
|
+
_KEY_BYTES = 32
|
|
30
|
+
_NONCE_BYTES = 12
|
|
31
|
+
_TAG_BYTES = 16
|
|
32
|
+
|
|
33
|
+
# Generic message only: never let a decode/decrypt failure path leak
|
|
34
|
+
# ciphertext, key material, or plaintext content into an error string.
|
|
35
|
+
_CORRUPT_MSG = "stored file credential is corrupt"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def new_data_key() -> str:
|
|
39
|
+
"""A fresh 32-byte AES key, hex-encoded (64 chars — printable, tiny)."""
|
|
40
|
+
random_bytes: bytes = get_random_bytes(_KEY_BYTES)
|
|
41
|
+
return random_bytes.hex()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def seal(key_id: str, data_key_hex: str, plaintext: str) -> bytes:
|
|
45
|
+
"""Encrypt `plaintext` under `data_key_hex`, bound to `key_id` via AAD."""
|
|
46
|
+
key = _decode_key(data_key_hex)
|
|
47
|
+
nonce: bytes = get_random_bytes(_NONCE_BYTES)
|
|
48
|
+
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
|
|
49
|
+
cipher.update(key_id.encode("utf-8"))
|
|
50
|
+
ciphertext: bytes
|
|
51
|
+
tag: bytes
|
|
52
|
+
ciphertext, tag = cipher.encrypt_and_digest(plaintext.encode("utf-8"))
|
|
53
|
+
return _MAGIC + nonce + tag + ciphertext
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def unseal(key_id: str, data_key_hex: str, blob: bytes) -> str:
|
|
57
|
+
"""Decrypt a `seal`-produced blob. Raises SecretStoreError on any
|
|
58
|
+
tampering, wrong key, or wrong key_id (AAD mismatch)."""
|
|
59
|
+
key = _decode_key(data_key_hex)
|
|
60
|
+
if len(blob) < len(_MAGIC) + _NONCE_BYTES + _TAG_BYTES or not blob.startswith(_MAGIC):
|
|
61
|
+
raise SecretStoreError(_CORRUPT_MSG)
|
|
62
|
+
rest = blob[len(_MAGIC) :]
|
|
63
|
+
nonce, tag, ciphertext = rest[:_NONCE_BYTES], rest[_NONCE_BYTES : _NONCE_BYTES + _TAG_BYTES], rest[_NONCE_BYTES + _TAG_BYTES :]
|
|
64
|
+
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
|
|
65
|
+
cipher.update(key_id.encode("utf-8"))
|
|
66
|
+
try:
|
|
67
|
+
plaintext: bytes = cipher.decrypt_and_verify(ciphertext, tag)
|
|
68
|
+
except ValueError as exc:
|
|
69
|
+
raise SecretStoreError(_CORRUPT_MSG) from exc
|
|
70
|
+
try:
|
|
71
|
+
return plaintext.decode("utf-8")
|
|
72
|
+
except UnicodeDecodeError as exc:
|
|
73
|
+
raise SecretStoreError(_CORRUPT_MSG) from exc
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _decode_key(data_key_hex: str) -> bytes:
|
|
77
|
+
try:
|
|
78
|
+
key = bytes.fromhex(data_key_hex)
|
|
79
|
+
except ValueError as exc:
|
|
80
|
+
raise SecretStoreError(_CORRUPT_MSG) from exc
|
|
81
|
+
if len(key) != _KEY_BYTES:
|
|
82
|
+
raise SecretStoreError(_CORRUPT_MSG)
|
|
83
|
+
return key
|