jishu-hub 0.1.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.
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.4
2
+ Name: jishu-hub
3
+ Version: 0.1.0
4
+ Summary: jishu Hub CLI for secure dataset and label transfer
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: httpx>=0.27
8
+ Requires-Dist: typer>=0.12
9
+ Requires-Dist: rich>=13.7
10
+ Requires-Dist: cryptography>=42
11
+
12
+ # jishu-hub
13
+
14
+ 安全传输 CLI:在**已授权设备**上上传/下载数据集、标注数据,以及拉取评测报告。
15
+
16
+ ---
17
+
18
+ ## 在其他机器上的使用流程
19
+
20
+ 每台新机器都要由根超管批准一次。密钥在本机生成,**不能**把旧机器的 `~/.jishu` 整包拷过去当「换机」。
21
+
22
+ ### 0. 目标机取机器码 → 根超管录入
23
+
24
+ 无一次性注册码。设备准入由超管按「用户 + 机器码」批准。
25
+
26
+ 1. 在**目标机器**安装 CLI 后执行:
27
+
28
+ ```bash
29
+ jishu device-code
30
+ ```
31
+
32
+ 将 64 位机器码发给管理员(工单 / 消息)。
33
+
34
+ 2. 根超管在 **后台管理 → 用户列表** 点「录入机器」:粘贴机器码、选填备注 → 批准。
35
+ 3. 管理员告知 **Hub 网关地址**(侧信道,勿写入公开文档)。
36
+
37
+ 设备与批准吊销在 **后台管理 → 设备/Hub**。
38
+
39
+ ### 1. 目标机器:环境
40
+
41
+ - Python ≥ 3.10
42
+ - 能访问 Hub 网关(HTTPS;生产环境通常要求客户端证书 / mTLS,由网关终止)
43
+ - 安装 CLI(**尚未上架公网 PyPI**,`pip install jishu-hub` 会失败):
44
+
45
+ ```bash
46
+ # 从本仓库开发安装(当前推荐)
47
+ cd /path/to/data_management
48
+ pip install -e ./cli
49
+
50
+ # 或安装已构建的 wheel(内网制品库 / 构建机产出)
51
+ # pip install /path/to/jishu_hub-0.1.0-py3-none-any.whl
52
+ # pip install jishu-hub --index-url https://<私有索引>
53
+ ```
54
+
55
+ 可选:固定网关环境变量:
56
+
57
+ ```bash
58
+ export JISHU_API_URL="https://<hub-host>" # Linux/macOS
59
+ # Windows PowerShell: $env:JISHU_API_URL="https://<hub-host>"
60
+ ```
61
+
62
+ ### 2. 登录(首次自动激活本机)
63
+
64
+ 管理员已录入本机机器码后:
65
+
66
+ ```bash
67
+ jishu login --gateway https://<hub-host> --username <平台用户名>
68
+ # 会提示密码;首次会自动 claim 设备证书,之后只需 login
69
+ ```
70
+
71
+ 成功后本机会生成:
72
+
73
+ | 位置 | 内容 |
74
+ |---|---|
75
+ | `~/.jishu/`(或 `JISHU_HOME`) | 网关、device_id、证书指纹等 |
76
+ | 本机密钥材料 | 私钥 + 设备证书(私钥不出机) |
77
+
78
+ 验证:
79
+
80
+ ```bash
81
+ jishu whoami
82
+ ```
83
+
84
+ ### 3. 常用命令
85
+
86
+ ```bash
87
+ jishu dataset upload <本地目录> --dataset-id <id> --version <ver>
88
+ jishu dataset download --dataset-id <id> --version <ver> --out <目录>
89
+ jishu label upload <标注目录> --project-id <id>
90
+ jishu eval reports list
91
+ ```
92
+
93
+ 上传/下载权限由平台账号 ACL 决定,与网页一致。
94
+
95
+ ### 4. 换机 / 吊销
96
+
97
+ - 换机:新机 `device-code` → 超管重新录入 → 新机 `login`
98
+ - 吊销:超管在 **设备/Hub** 吊销批准或设备后,该机无法再 login / 传输
@@ -0,0 +1,87 @@
1
+ # jishu-hub
2
+
3
+ 安全传输 CLI:在**已授权设备**上上传/下载数据集、标注数据,以及拉取评测报告。
4
+
5
+ ---
6
+
7
+ ## 在其他机器上的使用流程
8
+
9
+ 每台新机器都要由根超管批准一次。密钥在本机生成,**不能**把旧机器的 `~/.jishu` 整包拷过去当「换机」。
10
+
11
+ ### 0. 目标机取机器码 → 根超管录入
12
+
13
+ 无一次性注册码。设备准入由超管按「用户 + 机器码」批准。
14
+
15
+ 1. 在**目标机器**安装 CLI 后执行:
16
+
17
+ ```bash
18
+ jishu device-code
19
+ ```
20
+
21
+ 将 64 位机器码发给管理员(工单 / 消息)。
22
+
23
+ 2. 根超管在 **后台管理 → 用户列表** 点「录入机器」:粘贴机器码、选填备注 → 批准。
24
+ 3. 管理员告知 **Hub 网关地址**(侧信道,勿写入公开文档)。
25
+
26
+ 设备与批准吊销在 **后台管理 → 设备/Hub**。
27
+
28
+ ### 1. 目标机器:环境
29
+
30
+ - Python ≥ 3.10
31
+ - 能访问 Hub 网关(HTTPS;生产环境通常要求客户端证书 / mTLS,由网关终止)
32
+ - 安装 CLI(**尚未上架公网 PyPI**,`pip install jishu-hub` 会失败):
33
+
34
+ ```bash
35
+ # 从本仓库开发安装(当前推荐)
36
+ cd /path/to/data_management
37
+ pip install -e ./cli
38
+
39
+ # 或安装已构建的 wheel(内网制品库 / 构建机产出)
40
+ # pip install /path/to/jishu_hub-0.1.0-py3-none-any.whl
41
+ # pip install jishu-hub --index-url https://<私有索引>
42
+ ```
43
+
44
+ 可选:固定网关环境变量:
45
+
46
+ ```bash
47
+ export JISHU_API_URL="https://<hub-host>" # Linux/macOS
48
+ # Windows PowerShell: $env:JISHU_API_URL="https://<hub-host>"
49
+ ```
50
+
51
+ ### 2. 登录(首次自动激活本机)
52
+
53
+ 管理员已录入本机机器码后:
54
+
55
+ ```bash
56
+ jishu login --gateway https://<hub-host> --username <平台用户名>
57
+ # 会提示密码;首次会自动 claim 设备证书,之后只需 login
58
+ ```
59
+
60
+ 成功后本机会生成:
61
+
62
+ | 位置 | 内容 |
63
+ |---|---|
64
+ | `~/.jishu/`(或 `JISHU_HOME`) | 网关、device_id、证书指纹等 |
65
+ | 本机密钥材料 | 私钥 + 设备证书(私钥不出机) |
66
+
67
+ 验证:
68
+
69
+ ```bash
70
+ jishu whoami
71
+ ```
72
+
73
+ ### 3. 常用命令
74
+
75
+ ```bash
76
+ jishu dataset upload <本地目录> --dataset-id <id> --version <ver>
77
+ jishu dataset download --dataset-id <id> --version <ver> --out <目录>
78
+ jishu label upload <标注目录> --project-id <id>
79
+ jishu eval reports list
80
+ ```
81
+
82
+ 上传/下载权限由平台账号 ACL 决定,与网页一致。
83
+
84
+ ### 4. 换机 / 吊销
85
+
86
+ - 换机:新机 `device-code` → 超管重新录入 → 新机 `login`
87
+ - 吊销:超管在 **设备/Hub** 吊销批准或设备后,该机无法再 login / 传输
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "jishu-hub"
7
+ version = "0.1.0"
8
+ description = "jishu Hub CLI for secure dataset and label transfer"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = [
12
+ "httpx>=0.27",
13
+ "typer>=0.12",
14
+ "rich>=13.7",
15
+ "cryptography>=42",
16
+ ]
17
+
18
+ [project.scripts]
19
+ jishu = "jishu_hub.cli:main"
20
+
21
+ [tool.setuptools.packages.find]
22
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """jishu-hub CLI client."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from jishu_hub.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,335 @@
1
+ """jishu CLI 入口。"""
2
+ from __future__ import annotations
3
+
4
+ import zipfile
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ import typer
9
+ from rich import print as rprint
10
+
11
+ from jishu_hub import __version__, store
12
+ from jishu_hub.device_crypto import build_csr, device_fp_hash, save_device_cert
13
+ from jishu_hub.label_validate import validate_label_dir
14
+ from jishu_hub.rpc import HubClient, HubClientError
15
+ from jishu_hub.transfer_io import download_dataset, upload_directory
16
+
17
+ app = typer.Typer(add_completion=False, no_args_is_help=True, help="jishu Hub CLI")
18
+ dataset_app = typer.Typer(help="数据集上传/下载")
19
+ label_app = typer.Typer(help="智能标注")
20
+ eval_app = typer.Typer(help="评测报告")
21
+ app.add_typer(dataset_app, name="dataset")
22
+ app.add_typer(label_app, name="label")
23
+ reports_app = typer.Typer(help="报告")
24
+ eval_app.add_typer(reports_app, name="reports")
25
+ app.add_typer(eval_app, name="eval")
26
+
27
+
28
+ def _client(gateway: str | None = None) -> HubClient:
29
+ return HubClient(gateway=gateway)
30
+
31
+
32
+ @app.command("device-code")
33
+ def device_code_cmd() -> None:
34
+ """打印本机机器码,提交给根超管在后台录入。"""
35
+ fp = device_fp_hash()
36
+ rprint(fp)
37
+ rprint("[dim]将上述机器码发给管理员;录入后在本机执行 jishu login --gateway <url>[/dim]")
38
+
39
+
40
+ def _ensure_device_claimed(username: str, gateway: str | None) -> None:
41
+ """若本地尚无设备证书,则按超管批准自动 claim。"""
42
+ device = store.load_json("device")
43
+ cert = store.load_secret("device_cert")
44
+ if device.get("device_id") and cert:
45
+ return
46
+ gw = (gateway or store.gateway() or "").rstrip("/")
47
+ if not gw:
48
+ raise SystemExit("首次登录需指定网关:jishu login --gateway https://<hub-host>")
49
+ store.save_json("gateway", {"gateway_url": gw})
50
+ csr = build_csr()
51
+ fp = device_fp_hash()
52
+ client = HubClient(gateway=gw)
53
+ try:
54
+ result = client.claim(username, csr, fp)
55
+ except HubClientError as e:
56
+ rprint(f"[red]device claim failed:[/red] {e.code}")
57
+ if e.code == "machine_not_approved":
58
+ rprint(
59
+ "[yellow]本机尚未被管理员批准。请执行 jishu device-code 并将机器码发给根超管录入。[/yellow]"
60
+ )
61
+ raise typer.Exit(1)
62
+ save_device_cert(result.get("cert_pem") or "")
63
+ store.save_json(
64
+ "device",
65
+ {
66
+ "device_id": result.get("device_id"),
67
+ "cert_fingerprint": result.get("cert_fingerprint"),
68
+ "device_fp_hash": fp,
69
+ "scopes": result.get("scopes") or [],
70
+ "cli_min_version": result.get("cli_min_version"),
71
+ },
72
+ )
73
+ pins = result.get("tls_pins") or []
74
+ store.save_json(
75
+ "gateway",
76
+ {"gateway_url": result.get("gateway_url") or gw, "tls_pins": pins},
77
+ )
78
+ rprint(f"[green]device claimed[/green] device={result.get('device_id')}")
79
+
80
+
81
+ @app.command("login")
82
+ def login_cmd(
83
+ username: str = typer.Option(..., prompt=True),
84
+ password: str = typer.Option(..., prompt=True, hide_input=True),
85
+ gateway: Optional[str] = typer.Option(
86
+ None,
87
+ "--gateway",
88
+ "-g",
89
+ envvar="JISHU_API_URL",
90
+ help="Hub 网关 URL(首次登录或本机未配置时必填)",
91
+ ),
92
+ ) -> None:
93
+ """登录;若本机尚未激活,会先按管理员录入的机器码自动 claim。"""
94
+ try:
95
+ _ensure_device_claimed(username, gateway)
96
+ client = _client(gateway)
97
+ out = client.login(username, password)
98
+ except HubClientError as e:
99
+ rprint(f"[red]login failed:[/red] {e.code}")
100
+ raise typer.Exit(1)
101
+ rprint(f"[green]logged in[/green] scopes={out.get('scope')}")
102
+
103
+
104
+ @app.command("logout")
105
+ def logout_cmd() -> None:
106
+ try:
107
+ client = _client()
108
+ client.rpc("auth.logout", {})
109
+ except Exception:
110
+ pass
111
+ store.delete_secret("access_token")
112
+ store.delete_secret("refresh_token")
113
+ rprint("logged out")
114
+
115
+
116
+ @app.command("whoami")
117
+ def whoami_cmd() -> None:
118
+ try:
119
+ me = _client().rpc("auth.me", {})
120
+ except HubClientError as e:
121
+ rprint(f"[red]{e.code}[/red]")
122
+ raise typer.Exit(1)
123
+ rprint(me)
124
+
125
+
126
+ @dataset_app.command("upload")
127
+ def dataset_upload(
128
+ local_dir: Path = typer.Argument(..., exists=True, file_okay=False),
129
+ dataset_id: str = typer.Option(..., "--id"),
130
+ version: Optional[str] = typer.Option(
131
+ None, "--version", help="版本标签(分支指针);不存在则创建"
132
+ ),
133
+ ref: Optional[str] = typer.Option(None, "--ref", help="与 --version 同义"),
134
+ ) -> None:
135
+ ver = (version or ref or "").strip()
136
+ if not ver:
137
+ rprint("[red]need --version or --ref[/red]")
138
+ raise typer.Exit(2)
139
+ try:
140
+ client = _client()
141
+ upload_directory(
142
+ client,
143
+ local_dir,
144
+ dataset_id=dataset_id,
145
+ version=ver,
146
+ )
147
+ except HubClientError as e:
148
+ rprint(f"[red]upload failed:[/red] {e.code} {e.message}")
149
+ raise typer.Exit(1)
150
+ rprint("[green]upload done[/green]")
151
+
152
+
153
+ @dataset_app.command("download")
154
+ def dataset_download_cmd(
155
+ dataset_id: str = typer.Option(..., "--id"),
156
+ version: str = typer.Option(..., "--version"),
157
+ out: Path = typer.Option(Path("./download"), "--out"),
158
+ paths: Optional[str] = typer.Option(None, "--paths", help="逗号分隔相对路径"),
159
+ incremental: bool = typer.Option(True, "--incremental/--full"),
160
+ ) -> None:
161
+ path_list = [p.strip() for p in (paths or "").split(",") if p.strip()]
162
+ try:
163
+ download_dataset(
164
+ _client(),
165
+ dataset_id=dataset_id,
166
+ version=version,
167
+ out_dir=out,
168
+ paths=path_list or None,
169
+ incremental=incremental,
170
+ )
171
+ except HubClientError as e:
172
+ rprint(f"[red]download failed:[/red] {e.code}")
173
+ raise typer.Exit(1)
174
+ except ValueError as e:
175
+ rprint(f"[red]path error:[/red] {e}")
176
+ raise typer.Exit(1)
177
+ rprint(f"[green]downloaded to[/green] {out}")
178
+
179
+
180
+ @dataset_app.command("pull")
181
+ def dataset_pull(
182
+ dataset_id: str = typer.Option(..., "--id"),
183
+ version: str = typer.Option(..., "--version"),
184
+ out: Path = typer.Option(Path("./download"), "--out"),
185
+ ) -> None:
186
+ """download 别名。"""
187
+ dataset_download_cmd(dataset_id=dataset_id, version=version, out=out, paths=None, incremental=True)
188
+
189
+
190
+ @label_app.command("validate")
191
+ def label_validate_cmd(
192
+ local_dir: Path = typer.Argument(..., exists=True, file_okay=False),
193
+ project: str = typer.Option("", "--project", help="标注项目 id,可选拉 upload-spec"),
194
+ ) -> None:
195
+ media_type = "image"
196
+ if project:
197
+ try:
198
+ spec = _client().rpc("label.upload_spec", {"label_id": project})
199
+ media_type = str(spec.get("media_type") or "image")
200
+ except HubClientError as e:
201
+ rprint(f"[yellow]upload_spec skipped:[/yellow] {e.code}")
202
+ errors = validate_label_dir(local_dir, media_type=media_type)
203
+ if errors:
204
+ for e in errors:
205
+ rprint(f"[red]- {e}[/red]")
206
+ raise typer.Exit(1)
207
+ rprint("[green]validate ok[/green]")
208
+
209
+
210
+ @label_app.command("upload")
211
+ def label_upload_cmd(
212
+ local_dir: Path = typer.Argument(..., exists=True, file_okay=False),
213
+ project: str = typer.Option(..., "--project"),
214
+ ) -> None:
215
+ errors = validate_label_dir(local_dir)
216
+ if errors:
217
+ for e in errors:
218
+ rprint(f"[red]- {e}[/red]")
219
+ raise typer.Exit(1)
220
+
221
+ # pack zip like UI: assets + annotations if present, else wrap flat
222
+ import tempfile
223
+
224
+ client = _client()
225
+ with tempfile.TemporaryDirectory() as td:
226
+ zip_path = Path(td) / "label.zip"
227
+ with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
228
+ for p in local_dir.rglob("*"):
229
+ if p.is_file() and not p.is_symlink():
230
+ arc = p.relative_to(local_dir).as_posix()
231
+ if ".." in arc.split("/"):
232
+ raise typer.Exit(1)
233
+ zf.write(p, arcname=arc)
234
+ size = zip_path.stat().st_size
235
+ payload = {
236
+ "label_id": project,
237
+ "filename": "label.zip",
238
+ "file_size": size,
239
+ }
240
+ try:
241
+ init = client.rpc(
242
+ "label.archive.init",
243
+ payload,
244
+ with_challenge=True,
245
+ resource_id=project,
246
+ )
247
+ import httpx
248
+
249
+ parts = init.get("parts") or []
250
+ part_size = int(init.get("part_size") or size)
251
+ done = []
252
+ with zip_path.open("rb") as f, httpx.Client(timeout=300.0) as http:
253
+ for pinfo in parts:
254
+ pn = int(pinfo["part_number"])
255
+ start = (pn - 1) * part_size
256
+ end = min(start + part_size, size)
257
+ f.seek(start)
258
+ chunk = f.read(end - start)
259
+ r = http.put(pinfo["url"], content=chunk)
260
+ r.raise_for_status()
261
+ etag = (r.headers.get("ETag") or "").strip('"')
262
+ done.append({"part_number": pn, "etag": etag})
263
+ client.rpc(
264
+ "label.archive.complete",
265
+ {
266
+ "transfer_session_id": init["transfer_session_id"],
267
+ "parts": done,
268
+ },
269
+ )
270
+ batch = init.get("batch_id")
271
+ if batch:
272
+ st = client.rpc(
273
+ "label.archive.status",
274
+ {"label_id": project, "batch_id": batch},
275
+ )
276
+ rprint(st)
277
+ except HubClientError as e:
278
+ rprint(f"[red]label upload failed:[/red] {e.code}")
279
+ raise typer.Exit(1)
280
+ rprint("[green]label upload submitted[/green]")
281
+
282
+
283
+ @reports_app.command("list")
284
+ def eval_reports_list(
285
+ scope: str = typer.Option("mine", "--scope"),
286
+ team_id: Optional[int] = typer.Option(None, "--team-id"),
287
+ ) -> None:
288
+ payload: dict = {"scope": scope}
289
+ if team_id is not None:
290
+ payload["team_id"] = team_id
291
+ try:
292
+ out = _client().rpc("eval.reports.list", payload)
293
+ except HubClientError as e:
294
+ rprint(f"[red]{e.code}[/red]")
295
+ raise typer.Exit(1)
296
+ rprint(out)
297
+
298
+
299
+ @reports_app.command("pull")
300
+ def eval_reports_pull(
301
+ report_name: str = typer.Argument(...),
302
+ out: Path = typer.Option(Path("./report.json"), "--out"),
303
+ scope: str = typer.Option("mine", "--scope"),
304
+ team_id: Optional[int] = typer.Option(None, "--team-id"),
305
+ ) -> None:
306
+ payload: dict = {"report_name": report_name, "scope": scope}
307
+ if team_id is not None:
308
+ payload["team_id"] = team_id
309
+ try:
310
+ out_data = _client().rpc("eval.reports.pull_meta", payload)
311
+ except HubClientError as e:
312
+ rprint(f"[red]{e.code}[/red]")
313
+ raise typer.Exit(1)
314
+ import json
315
+
316
+ out.write_text(json.dumps(out_data, ensure_ascii=False, indent=2), encoding="utf-8")
317
+ rprint(f"[green]wrote[/green] {out}")
318
+
319
+
320
+ @app.callback()
321
+ def main_callback(
322
+ version: bool = typer.Option(False, "--version", help="Show version"),
323
+ ) -> None:
324
+ if version:
325
+ rprint(__version__)
326
+ raise typer.Exit()
327
+
328
+
329
+ # Typer wants `app` callable for console_scripts — expose as main
330
+ def main() -> None:
331
+ app()
332
+
333
+
334
+ if __name__ == "__main__":
335
+ main()
@@ -0,0 +1,70 @@
1
+ """设备密钥:本地生成,私钥不离开本机。"""
2
+ from __future__ import annotations
3
+
4
+ import base64
5
+ import hashlib
6
+ import json
7
+ import platform
8
+ import uuid
9
+ from pathlib import Path
10
+
11
+ from cryptography import x509
12
+ from cryptography.hazmat.primitives import hashes, serialization
13
+ from cryptography.hazmat.primitives.asymmetric import padding, rsa
14
+ from cryptography.x509.oid import NameOID
15
+
16
+ from jishu_hub import store
17
+
18
+
19
+ def device_fp_hash() -> str:
20
+ raw = "|".join(
21
+ [
22
+ platform.node(),
23
+ platform.system(),
24
+ platform.machine(),
25
+ str(uuid.getnode()),
26
+ str(Path.home()),
27
+ ]
28
+ )
29
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()
30
+
31
+
32
+ def ensure_key_pair() -> rsa.RSAPrivateKey:
33
+ pem = store.load_secret("device_key")
34
+ if pem:
35
+ return serialization.load_pem_private_key(pem.encode("utf-8"), password=None) # type: ignore[return-value]
36
+ key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
37
+ store.save_secret(
38
+ "device_key",
39
+ key.private_bytes(
40
+ encoding=serialization.Encoding.PEM,
41
+ format=serialization.PrivateFormat.PKCS8,
42
+ encryption_algorithm=serialization.NoEncryption(),
43
+ ).decode("utf-8"),
44
+ )
45
+ return key
46
+
47
+
48
+ def build_csr(device_cn: str = "jishu-device") -> str:
49
+ key = ensure_key_pair()
50
+ csr = (
51
+ x509.CertificateSigningRequestBuilder()
52
+ .subject_name(
53
+ x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, device_cn[:64])])
54
+ )
55
+ .sign(key, hashes.SHA256())
56
+ )
57
+ return csr.public_bytes(serialization.Encoding.PEM).decode("utf-8")
58
+
59
+
60
+ def sign_challenge_message(message_obj: dict) -> str:
61
+ key = ensure_key_pair()
62
+ msg = json.dumps(message_obj, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode(
63
+ "utf-8"
64
+ )
65
+ sig = key.sign(msg, padding.PKCS1v15(), hashes.SHA256())
66
+ return base64.b64encode(sig).decode("ascii")
67
+
68
+
69
+ def save_device_cert(cert_pem: str) -> None:
70
+ store.save_secret("device_cert", cert_pem)