wimotek-edge-agent 1.0.8 → 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 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 Replay Protection**: `jti` is stored in SQLite `used_tokens` until expiration. Subsequent attempts are rejected with `token replay detected`.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wimotek-edge-agent",
3
- "version": "1.0.8",
3
+ "version": "1.0.9",
4
4
  "description": "Universal Edge Reverse Proxy & Service Bridge Agent CLI Runner",
5
5
  "bin": {
6
6
  "wimotek-edge-agent": "./bin/cli.js",
package/pyproject.toml CHANGED
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
6
6
 
7
7
  [project]
8
8
  name = "wimotek-edge-agent"
9
- version = "1.0.8"
9
+ version = "1.0.9"
10
10
  description = "Universal Edge Reverse Proxy & Service Bridge Agent"
11
11
  readme = "README.md"
12
12
  requires-python = ">=3.9"
@@ -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=secrets.token_urlsafe(24),
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
- payload = json.dumps(
81
- asdict(claims), separators=(",", ":"), sort_keys=True
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 consume_token(self, jti: str, expires_at: int) -> bool:
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