wimotek-edge-agent 1.0.7 → 1.0.9
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.
- package/README.md +2 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/wimotek_edge_agent/agent.py +2 -0
- package/wimotek_edge_agent/tokens.py +132 -6
- package/wimotek_edge_agent/trust_store.py +85 -1
package/README.md
CHANGED
|
@@ -375,7 +375,8 @@ Ship this structured log file to your central logging pipeline (Loki, ELK, Cloud
|
|
|
375
375
|
## Security model
|
|
376
376
|
|
|
377
377
|
- **Token Secret**: >= 32 bytes HMAC secret.
|
|
378
|
-
- **Single-Use
|
|
378
|
+
- **Compact 40-Character Single-Use HMAC Token**: 14-byte high-entropy ID (112 bits) + 16-byte HMAC-SHA256 signature (128 bits) = 30 raw bytes, Base64URL encoded to strictly 40 characters without padding. Replaces legacy verbose >200-character JWT-style strings while maintaining `O(1)` cryptographic tamper-proof validation.
|
|
379
|
+
- **Single-Use Replay Protection**: Claims are stored in SQLite `issued_tokens` and atomically transferred to `used_tokens` upon first verification. Subsequent attempts are rejected with `token replay detected`.
|
|
379
380
|
- **Identity & Network Binding**: Tokens can be bound to specific `node_uuid` and `allowed_ip_cidr` networks.
|
|
380
381
|
- **TOFU Quarantine**: Unapproved nodes only exchange heartbeats; business data relay is strictly forbidden.
|
|
381
382
|
- **No Downgrade**: Trusted nodes presenting without credentials cannot downgrade to TOFU.
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -49,6 +49,7 @@ class EdgeAgent:
|
|
|
49
49
|
|
|
50
50
|
self.token_issuer = JoinTokenIssuer(
|
|
51
51
|
secret=config.token_secret,
|
|
52
|
+
store=self.trust_store,
|
|
52
53
|
audience=config.token_audience,
|
|
53
54
|
issuer=config.token_issuer,
|
|
54
55
|
max_ttl_seconds=config.token_max_ttl_seconds,
|
|
@@ -56,6 +57,7 @@ class EdgeAgent:
|
|
|
56
57
|
self.token_verifier = JoinTokenVerifier(
|
|
57
58
|
secret=config.token_secret,
|
|
58
59
|
replay_store=self.trust_store,
|
|
60
|
+
store=self.trust_store,
|
|
59
61
|
audience=config.token_audience,
|
|
60
62
|
issuer=config.token_issuer,
|
|
61
63
|
max_ttl_seconds=config.token_max_ttl_seconds,
|
|
@@ -20,6 +20,46 @@ class TokenReplayStore(Protocol):
|
|
|
20
20
|
...
|
|
21
21
|
|
|
22
22
|
|
|
23
|
+
class TokenStore(Protocol):
|
|
24
|
+
def save_issued_token(self, token: str, claims: JoinTokenClaims) -> None:
|
|
25
|
+
...
|
|
26
|
+
|
|
27
|
+
def get_issued_token(self, token: str) -> Optional[JoinTokenClaims]:
|
|
28
|
+
...
|
|
29
|
+
|
|
30
|
+
def consume_token(self, jti: str, expires_at: int) -> bool:
|
|
31
|
+
...
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class InMemoryTokenStore:
|
|
35
|
+
"""轻量内存 Token 存储,供单测或无 SQLite 场景使用。"""
|
|
36
|
+
|
|
37
|
+
def __init__(self) -> None:
|
|
38
|
+
self._tokens: dict[str, JoinTokenClaims] = {}
|
|
39
|
+
self._used: set[str] = set()
|
|
40
|
+
|
|
41
|
+
def save_issued_token(self, token: str, claims: JoinTokenClaims) -> None:
|
|
42
|
+
self._tokens[token] = claims
|
|
43
|
+
|
|
44
|
+
def get_issued_token(self, token: str) -> Optional[JoinTokenClaims]:
|
|
45
|
+
now = int(time.time())
|
|
46
|
+
claims = self._tokens.get(token)
|
|
47
|
+
if claims and claims.exp < now:
|
|
48
|
+
self._tokens.pop(token, None)
|
|
49
|
+
return None
|
|
50
|
+
return claims
|
|
51
|
+
|
|
52
|
+
def is_token_used(self, jti: str) -> bool:
|
|
53
|
+
return jti in self._used
|
|
54
|
+
|
|
55
|
+
def consume_token(self, jti: str, expires_at: int) -> bool:
|
|
56
|
+
if jti in self._used:
|
|
57
|
+
return False
|
|
58
|
+
self._used.add(jti)
|
|
59
|
+
self._tokens.pop(jti, None)
|
|
60
|
+
return True
|
|
61
|
+
|
|
62
|
+
|
|
23
63
|
def _b64e(data: bytes) -> str:
|
|
24
64
|
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
25
65
|
|
|
@@ -42,9 +82,16 @@ class JoinTokenClaims:
|
|
|
42
82
|
|
|
43
83
|
|
|
44
84
|
class JoinTokenIssuer:
|
|
85
|
+
"""
|
|
86
|
+
签发单次准入令牌。
|
|
87
|
+
采用 30 字节紧凑编码 (14 字节安全随机 ID + 16 字节 HMAC-SHA256 签名),
|
|
88
|
+
Base64URL 编码后长度严格为 40 个字符,摆脱 >200 字符的冗长 JWT 结构。
|
|
89
|
+
"""
|
|
90
|
+
|
|
45
91
|
def __init__(
|
|
46
92
|
self,
|
|
47
93
|
secret: bytes,
|
|
94
|
+
store: Optional[TokenStore] = None,
|
|
48
95
|
audience: str = "edge-agent",
|
|
49
96
|
issuer: str = "central",
|
|
50
97
|
max_ttl_seconds: int = 600,
|
|
@@ -52,6 +99,7 @@ class JoinTokenIssuer:
|
|
|
52
99
|
if len(secret) < 32:
|
|
53
100
|
raise ValueError("token secret must be at least 32 bytes")
|
|
54
101
|
self.secret = secret
|
|
102
|
+
self.store: TokenStore = store if store is not None else InMemoryTokenStore()
|
|
55
103
|
self.audience = audience
|
|
56
104
|
self.issuer = issuer
|
|
57
105
|
self.max_ttl_seconds = max_ttl_seconds
|
|
@@ -67,8 +115,18 @@ class JoinTokenIssuer:
|
|
|
67
115
|
raise ValueError("ttl_seconds out of allowed range")
|
|
68
116
|
|
|
69
117
|
now = int(time.time())
|
|
118
|
+
|
|
119
|
+
# 14 字节密码学高熵安全随机数 (112 位熵)
|
|
120
|
+
token_id = secrets.token_bytes(14)
|
|
121
|
+
# 16 字节 HMAC-SHA256 截断签名 (128 位数字签名防伪)
|
|
122
|
+
sig = hmac.new(self.secret, token_id, hashlib.sha256).digest()[:16]
|
|
123
|
+
# 拼接为恰好 30 字节的紧凑二进制数据
|
|
124
|
+
raw = token_id + sig
|
|
125
|
+
# Base64URL 编码后长度严格为 40 个无填充字符
|
|
126
|
+
token = _b64e(raw)
|
|
127
|
+
|
|
70
128
|
claims = JoinTokenClaims(
|
|
71
|
-
jti=
|
|
129
|
+
jti=token,
|
|
72
130
|
exp=now + ttl_seconds,
|
|
73
131
|
iat=now,
|
|
74
132
|
aud=self.audience,
|
|
@@ -77,11 +135,8 @@ class JoinTokenIssuer:
|
|
|
77
135
|
node_uuid=node_uuid,
|
|
78
136
|
allowed_ip_cidr=allowed_ip_cidr,
|
|
79
137
|
)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
).encode("utf-8")
|
|
83
|
-
signature = hmac.new(self.secret, payload, hashlib.sha256).digest()
|
|
84
|
-
return f"{_b64e(payload)}.{_b64e(signature)}"
|
|
138
|
+
self.store.save_issued_token(token, claims)
|
|
139
|
+
return token
|
|
85
140
|
|
|
86
141
|
|
|
87
142
|
class JoinTokenVerifier:
|
|
@@ -89,6 +144,7 @@ class JoinTokenVerifier:
|
|
|
89
144
|
self,
|
|
90
145
|
secret: bytes,
|
|
91
146
|
replay_store: TokenReplayStore,
|
|
147
|
+
store: Optional[TokenStore] = None,
|
|
92
148
|
audience: str = "edge-agent",
|
|
93
149
|
issuer: str = "central",
|
|
94
150
|
max_ttl_seconds: int = 600,
|
|
@@ -97,6 +153,12 @@ class JoinTokenVerifier:
|
|
|
97
153
|
raise ValueError("token secret must be at least 32 bytes")
|
|
98
154
|
self.secret = secret
|
|
99
155
|
self.replay_store = replay_store
|
|
156
|
+
if store is not None:
|
|
157
|
+
self.store = store
|
|
158
|
+
elif hasattr(replay_store, "get_issued_token"):
|
|
159
|
+
self.store = replay_store # type: ignore
|
|
160
|
+
else:
|
|
161
|
+
self.store = InMemoryTokenStore()
|
|
100
162
|
self.audience = audience
|
|
101
163
|
self.issuer = issuer
|
|
102
164
|
self.max_ttl_seconds = max_ttl_seconds
|
|
@@ -106,6 +168,70 @@ class JoinTokenVerifier:
|
|
|
106
168
|
token: str,
|
|
107
169
|
source_ip: str,
|
|
108
170
|
node_uuid: str,
|
|
171
|
+
) -> JoinTokenClaims:
|
|
172
|
+
# 兼容旧版带句号 "." 的 JWT 长票据
|
|
173
|
+
if "." in token:
|
|
174
|
+
return self._verify_legacy_jwt(token, source_ip, node_uuid)
|
|
175
|
+
|
|
176
|
+
# 紧凑型 40 字符 Token 验证
|
|
177
|
+
try:
|
|
178
|
+
raw = _b64d(token)
|
|
179
|
+
if len(raw) != 30:
|
|
180
|
+
raise TokenError("malformed token length")
|
|
181
|
+
token_id = raw[:14]
|
|
182
|
+
signature = raw[14:]
|
|
183
|
+
except Exception as exc:
|
|
184
|
+
raise TokenError("malformed token") from exc
|
|
185
|
+
|
|
186
|
+
# O(1) 密码学签名核对,拦截伪造与垃圾请求
|
|
187
|
+
expected = hmac.new(self.secret, token_id, hashlib.sha256).digest()[:16]
|
|
188
|
+
if not hmac.compare_digest(signature, expected):
|
|
189
|
+
raise TokenError("bad token signature")
|
|
190
|
+
|
|
191
|
+
# 载荷查询
|
|
192
|
+
claims = self.store.get_issued_token(token)
|
|
193
|
+
if claims is None:
|
|
194
|
+
# 明确区分:如果在 used_tokens 中已记录,则准确判定为重放拦截
|
|
195
|
+
if hasattr(self.replay_store, "is_token_used") and self.replay_store.is_token_used(token):
|
|
196
|
+
raise TokenError("token replay detected")
|
|
197
|
+
if hasattr(self.store, "is_token_used") and self.store.is_token_used(token):
|
|
198
|
+
raise TokenError("token replay detected")
|
|
199
|
+
raise TokenError("token not found or expired")
|
|
200
|
+
|
|
201
|
+
now = int(time.time())
|
|
202
|
+
if claims.exp <= now:
|
|
203
|
+
raise TokenError("token expired")
|
|
204
|
+
if claims.iat > now + 60:
|
|
205
|
+
raise TokenError("token issued in the future")
|
|
206
|
+
if claims.exp - claims.iat > self.max_ttl_seconds:
|
|
207
|
+
raise TokenError("token ttl too long")
|
|
208
|
+
if claims.aud != self.audience:
|
|
209
|
+
raise TokenError("token audience mismatch")
|
|
210
|
+
if claims.iss != self.issuer:
|
|
211
|
+
raise TokenError("token issuer mismatch")
|
|
212
|
+
|
|
213
|
+
if claims.node_uuid is not None and claims.node_uuid != node_uuid:
|
|
214
|
+
raise TokenError("token node_uuid mismatch")
|
|
215
|
+
|
|
216
|
+
if claims.allowed_ip_cidr:
|
|
217
|
+
try:
|
|
218
|
+
network = ipaddress.ip_network(claims.allowed_ip_cidr, strict=False)
|
|
219
|
+
address = ipaddress.ip_address(source_ip)
|
|
220
|
+
except ValueError as exc:
|
|
221
|
+
raise TokenError("invalid token ip binding or source ip") from exc
|
|
222
|
+
if address not in network:
|
|
223
|
+
raise TokenError("source ip not allowed by token")
|
|
224
|
+
|
|
225
|
+
if not self.replay_store.consume_token(claims.jti, claims.exp):
|
|
226
|
+
raise TokenError("token replay detected")
|
|
227
|
+
|
|
228
|
+
return claims
|
|
229
|
+
|
|
230
|
+
def _verify_legacy_jwt(
|
|
231
|
+
self,
|
|
232
|
+
token: str,
|
|
233
|
+
source_ip: str,
|
|
234
|
+
node_uuid: str,
|
|
109
235
|
) -> JoinTokenClaims:
|
|
110
236
|
try:
|
|
111
237
|
payload_b64, signature_b64 = token.split(".", 1)
|
|
@@ -54,6 +54,22 @@ class TrustStore:
|
|
|
54
54
|
)
|
|
55
55
|
"""
|
|
56
56
|
)
|
|
57
|
+
conn.execute(
|
|
58
|
+
"""
|
|
59
|
+
CREATE TABLE IF NOT EXISTS issued_tokens (
|
|
60
|
+
token TEXT PRIMARY KEY,
|
|
61
|
+
jti TEXT NOT NULL UNIQUE,
|
|
62
|
+
exp INTEGER NOT NULL,
|
|
63
|
+
iat INTEGER NOT NULL,
|
|
64
|
+
aud TEXT NOT NULL,
|
|
65
|
+
iss TEXT NOT NULL,
|
|
66
|
+
node_group TEXT NOT NULL,
|
|
67
|
+
node_uuid TEXT,
|
|
68
|
+
allowed_ip_cidr TEXT,
|
|
69
|
+
created_at TEXT NOT NULL
|
|
70
|
+
)
|
|
71
|
+
"""
|
|
72
|
+
)
|
|
57
73
|
|
|
58
74
|
def get_node(self, node_uuid: str) -> Optional[NodeRecord]:
|
|
59
75
|
with self._connect() as conn:
|
|
@@ -232,15 +248,79 @@ class TrustStore:
|
|
|
232
248
|
"used_tokens_count": used_tokens_count,
|
|
233
249
|
}
|
|
234
250
|
|
|
235
|
-
def
|
|
251
|
+
def save_issued_token(self, token: str, claims) -> None:
|
|
252
|
+
"""
|
|
253
|
+
持久化 40 字符紧凑 Token 对应的 claims 载荷。
|
|
254
|
+
"""
|
|
255
|
+
with self._connect() as conn:
|
|
256
|
+
conn.execute(
|
|
257
|
+
"""
|
|
258
|
+
INSERT OR REPLACE INTO issued_tokens
|
|
259
|
+
(token, jti, exp, iat, aud, iss, node_group, node_uuid, allowed_ip_cidr, created_at)
|
|
260
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
261
|
+
""",
|
|
262
|
+
(
|
|
263
|
+
token,
|
|
264
|
+
claims.jti,
|
|
265
|
+
claims.exp,
|
|
266
|
+
claims.iat,
|
|
267
|
+
claims.aud,
|
|
268
|
+
claims.iss,
|
|
269
|
+
claims.node_group,
|
|
270
|
+
claims.node_uuid,
|
|
271
|
+
claims.allowed_ip_cidr,
|
|
272
|
+
_utc_now(),
|
|
273
|
+
),
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
def get_issued_token(self, token: str):
|
|
277
|
+
"""
|
|
278
|
+
读取 Token 对应的 claims 载荷并过滤过期项。
|
|
279
|
+
"""
|
|
280
|
+
from .tokens import JoinTokenClaims
|
|
281
|
+
|
|
282
|
+
now_ts = int(time.time())
|
|
283
|
+
with self._connect() as conn:
|
|
284
|
+
conn.execute("DELETE FROM issued_tokens WHERE exp < ?", (now_ts,))
|
|
285
|
+
row = conn.execute(
|
|
286
|
+
"SELECT * FROM issued_tokens WHERE token = ?", (token,)
|
|
287
|
+
).fetchone()
|
|
288
|
+
if row is None:
|
|
289
|
+
return None
|
|
290
|
+
return JoinTokenClaims(
|
|
291
|
+
jti=row["jti"],
|
|
292
|
+
exp=row["exp"],
|
|
293
|
+
iat=row["iat"],
|
|
294
|
+
aud=row["aud"],
|
|
295
|
+
iss=row["iss"],
|
|
296
|
+
node_group=row["node_group"],
|
|
297
|
+
node_uuid=row["node_uuid"],
|
|
298
|
+
allowed_ip_cidr=row["allowed_ip_cidr"],
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
def is_token_used(self, jti: str) -> bool:
|
|
302
|
+
"""
|
|
303
|
+
检查指定 jti 是否已被核销使用。
|
|
304
|
+
"""
|
|
305
|
+
with self._connect() as conn:
|
|
306
|
+
row = conn.execute(
|
|
307
|
+
"SELECT 1 FROM used_tokens WHERE jti = ?", (jti,)
|
|
308
|
+
).fetchone()
|
|
309
|
+
return row is not None
|
|
310
|
+
|
|
311
|
+
def consume_token(self, jti: str, expires_at: int, token: Optional[str] = None) -> bool:
|
|
236
312
|
"""
|
|
237
313
|
返回 True 表示首次使用;False 表示重放。
|
|
314
|
+
单次原子核销:写入 used_tokens 并从 issued_tokens 移除。
|
|
238
315
|
"""
|
|
239
316
|
now_ts = int(time.time())
|
|
240
317
|
with self._connect() as conn:
|
|
241
318
|
conn.execute(
|
|
242
319
|
"DELETE FROM used_tokens WHERE expires_at < ?", (now_ts,)
|
|
243
320
|
)
|
|
321
|
+
conn.execute(
|
|
322
|
+
"DELETE FROM issued_tokens WHERE exp < ?", (now_ts,)
|
|
323
|
+
)
|
|
244
324
|
try:
|
|
245
325
|
conn.execute(
|
|
246
326
|
"""
|
|
@@ -249,6 +329,10 @@ class TrustStore:
|
|
|
249
329
|
""",
|
|
250
330
|
(jti, expires_at, _utc_now()),
|
|
251
331
|
)
|
|
332
|
+
if token:
|
|
333
|
+
conn.execute("DELETE FROM issued_tokens WHERE token = ?", (token,))
|
|
334
|
+
else:
|
|
335
|
+
conn.execute("DELETE FROM issued_tokens WHERE jti = ?", (jti,))
|
|
252
336
|
return True
|
|
253
337
|
except sqlite3.IntegrityError:
|
|
254
338
|
return False
|