qsignt 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.
- qsignt-0.1.0/LICENSE +21 -0
- qsignt-0.1.0/PKG-INFO +90 -0
- qsignt-0.1.0/README.md +77 -0
- qsignt-0.1.0/pyproject.toml +55 -0
- qsignt-0.1.0/setup.cfg +4 -0
- qsignt-0.1.0/src/qsignt/__init__.py +25 -0
- qsignt-0.1.0/src/qsignt/box.py +87 -0
- qsignt-0.1.0/src/qsignt/cli.py +182 -0
- qsignt-0.1.0/src/qsignt/pgpx.py +149 -0
- qsignt-0.1.0/src/qsignt/proto.py +73 -0
- qsignt-0.1.0/src/qsignt.egg-info/PKG-INFO +90 -0
- qsignt-0.1.0/src/qsignt.egg-info/SOURCES.txt +15 -0
- qsignt-0.1.0/src/qsignt.egg-info/dependency_links.txt +1 -0
- qsignt-0.1.0/src/qsignt.egg-info/entry_points.txt +2 -0
- qsignt-0.1.0/src/qsignt.egg-info/requires.txt +1 -0
- qsignt-0.1.0/src/qsignt.egg-info/top_level.txt +1 -0
- qsignt-0.1.0/tests/test_proto.py +38 -0
qsignt-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AmritaBot
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
qsignt-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: qsignt
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: QsigNT signer 注册工具:OpenPGP 上行 + X25519 下行
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Repository, https://github.com/AmritaBot/qsignt.py
|
|
7
|
+
Project-URL: Issues, https://github.com/AmritaBot/qsignt.py/issues
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: cryptography>=42
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# qsignt
|
|
15
|
+
|
|
16
|
+
[](https://github.com/AmritaBot/qsignt.py/actions/workflows/ci.yml)
|
|
17
|
+
|
|
18
|
+
QsigNT signer 的注册工具。在 QQ 群里跑一遍流程,把 apikey 换到本地。
|
|
19
|
+
|
|
20
|
+
依赖:Python 3.10+、gnupg 2.3+(需要 `gpg` 在 PATH 里)。
|
|
21
|
+
|
|
22
|
+
## 签名申请入口
|
|
23
|
+
|
|
24
|
+
签名权限只在 QQ 群 **1132696493** 发放,进群后直接发 `#qsignt init` 就能开始,整个
|
|
25
|
+
流程在群里闭环,不需要单独找人。
|
|
26
|
+
|
|
27
|
+
群号固定写死在工具里,查一下就知道:
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
qsignt community
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
(不联网、不读配置,纯粹回显常量,防的是手抄错号。)
|
|
34
|
+
|
|
35
|
+
apikey 是组织层面的凭证,按用户发放。仓库这边不发 key、也不提供自助领取,抱着
|
|
36
|
+
clone 下来就跑出权限的期待会落空。
|
|
37
|
+
|
|
38
|
+
能拿到的东西:
|
|
39
|
+
|
|
40
|
+
- 一个 `qsig_` 开头的 apikey,由 QsigNT daemon 校验,请求头带 `Authorization: Bearer <apikey>`
|
|
41
|
+
- apikey 与用户一一对应,一人一把,不做共享
|
|
42
|
+
- 限速按用户计,默认 20 请求/秒,超了返回 429 和 `Retry-After`
|
|
43
|
+
|
|
44
|
+
注册流程只在群里走,私聊不办理。群聊之外的旁路申请一律不接,包括找维护者单独要。
|
|
45
|
+
|
|
46
|
+
## 为什么是 OpenPGP
|
|
47
|
+
|
|
48
|
+
QQ 客户端与服务端的身份已由平台担保,需要防的只是群里旁观者顺手读到凭证。
|
|
49
|
+
上行用服务端公钥加密就够了,不需要证书链,也不需要指纹交叉验证。
|
|
50
|
+
|
|
51
|
+
下行不用 OpenPGP,改用一次性 X25519 + AES-256-GCM:客户端在提交时带上本次流程
|
|
52
|
+
的临时公钥,服务端加密回包即可。少一次密钥交换,载荷也短一半。
|
|
53
|
+
|
|
54
|
+
## 流程
|
|
55
|
+
|
|
56
|
+
群里发 `#qsignt init`,机器人回一条带公钥的消息。整段复制,然后:
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
qsignt enroll --msg "SIGNER-KEY-V1 flow=AB12CD34 exp=10m
|
|
60
|
+
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
|
61
|
+
...
|
|
62
|
+
-----END PGP PUBLIC KEY BLOCK-----"
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
工具打印一条 `#qsignt key submit ...`,发到群里。
|
|
66
|
+
|
|
67
|
+
机器人回 `#qsignt key open ...`,复制后:
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
qsignt open --msg "#qsignt key open AbCd..."
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
工具把 apikey 存到 `~/.config/qsignt/token.json`(权限 0600),并打印
|
|
74
|
+
`#qsignt verify XXXXXXXX`。发出去,激活完成。
|
|
75
|
+
|
|
76
|
+
`qsignt show` 随时取回 apikey。
|
|
77
|
+
|
|
78
|
+
## 服务端
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
qsignt keygen --out /var/lib/qsignt/gpg # 生成 bot 密钥对,公钥贴到群里
|
|
82
|
+
qsignt decrypt --home /var/lib/qsignt/gpg --msg "..."
|
|
83
|
+
qsignt seal --eph <客户端公钥> --flow AB12CD34 --json reply.json
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## 边界
|
|
87
|
+
|
|
88
|
+
- 中间中断不会白费:流程状态存在本地,`open` 之前重跑 `enroll` 会重开新流程。
|
|
89
|
+
- 群消息会被折叠,复制时可能带上换行和空格,解析时已做容错。
|
|
90
|
+
- apikey 明文只落在本地文件里,工具不做上传、不做同步。
|
qsignt-0.1.0/README.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# qsignt
|
|
2
|
+
|
|
3
|
+
[](https://github.com/AmritaBot/qsignt.py/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
QsigNT signer 的注册工具。在 QQ 群里跑一遍流程,把 apikey 换到本地。
|
|
6
|
+
|
|
7
|
+
依赖:Python 3.10+、gnupg 2.3+(需要 `gpg` 在 PATH 里)。
|
|
8
|
+
|
|
9
|
+
## 签名申请入口
|
|
10
|
+
|
|
11
|
+
签名权限只在 QQ 群 **1132696493** 发放,进群后直接发 `#qsignt init` 就能开始,整个
|
|
12
|
+
流程在群里闭环,不需要单独找人。
|
|
13
|
+
|
|
14
|
+
群号固定写死在工具里,查一下就知道:
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
qsignt community
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
(不联网、不读配置,纯粹回显常量,防的是手抄错号。)
|
|
21
|
+
|
|
22
|
+
apikey 是组织层面的凭证,按用户发放。仓库这边不发 key、也不提供自助领取,抱着
|
|
23
|
+
clone 下来就跑出权限的期待会落空。
|
|
24
|
+
|
|
25
|
+
能拿到的东西:
|
|
26
|
+
|
|
27
|
+
- 一个 `qsig_` 开头的 apikey,由 QsigNT daemon 校验,请求头带 `Authorization: Bearer <apikey>`
|
|
28
|
+
- apikey 与用户一一对应,一人一把,不做共享
|
|
29
|
+
- 限速按用户计,默认 20 请求/秒,超了返回 429 和 `Retry-After`
|
|
30
|
+
|
|
31
|
+
注册流程只在群里走,私聊不办理。群聊之外的旁路申请一律不接,包括找维护者单独要。
|
|
32
|
+
|
|
33
|
+
## 为什么是 OpenPGP
|
|
34
|
+
|
|
35
|
+
QQ 客户端与服务端的身份已由平台担保,需要防的只是群里旁观者顺手读到凭证。
|
|
36
|
+
上行用服务端公钥加密就够了,不需要证书链,也不需要指纹交叉验证。
|
|
37
|
+
|
|
38
|
+
下行不用 OpenPGP,改用一次性 X25519 + AES-256-GCM:客户端在提交时带上本次流程
|
|
39
|
+
的临时公钥,服务端加密回包即可。少一次密钥交换,载荷也短一半。
|
|
40
|
+
|
|
41
|
+
## 流程
|
|
42
|
+
|
|
43
|
+
群里发 `#qsignt init`,机器人回一条带公钥的消息。整段复制,然后:
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
qsignt enroll --msg "SIGNER-KEY-V1 flow=AB12CD34 exp=10m
|
|
47
|
+
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
|
48
|
+
...
|
|
49
|
+
-----END PGP PUBLIC KEY BLOCK-----"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
工具打印一条 `#qsignt key submit ...`,发到群里。
|
|
53
|
+
|
|
54
|
+
机器人回 `#qsignt key open ...`,复制后:
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
qsignt open --msg "#qsignt key open AbCd..."
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
工具把 apikey 存到 `~/.config/qsignt/token.json`(权限 0600),并打印
|
|
61
|
+
`#qsignt verify XXXXXXXX`。发出去,激活完成。
|
|
62
|
+
|
|
63
|
+
`qsignt show` 随时取回 apikey。
|
|
64
|
+
|
|
65
|
+
## 服务端
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
qsignt keygen --out /var/lib/qsignt/gpg # 生成 bot 密钥对,公钥贴到群里
|
|
69
|
+
qsignt decrypt --home /var/lib/qsignt/gpg --msg "..."
|
|
70
|
+
qsignt seal --eph <客户端公钥> --flow AB12CD34 --json reply.json
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## 边界
|
|
74
|
+
|
|
75
|
+
- 中间中断不会白费:流程状态存在本地,`open` 之前重跑 `enroll` 会重开新流程。
|
|
76
|
+
- 群消息会被折叠,复制时可能带上换行和空格,解析时已做容错。
|
|
77
|
+
- apikey 明文只落在本地文件里,工具不做上传、不做同步。
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "qsignt"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "QsigNT signer 注册工具:OpenPGP 上行 + X25519 下行"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
dependencies = ["cryptography>=42"]
|
|
13
|
+
|
|
14
|
+
[project.urls]
|
|
15
|
+
Repository = "https://github.com/AmritaBot/qsignt.py"
|
|
16
|
+
Issues = "https://github.com/AmritaBot/qsignt.py/issues"
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
qsignt = "qsignt.cli:main"
|
|
20
|
+
|
|
21
|
+
[dependency-groups]
|
|
22
|
+
dev = ["pytest>=8", "ruff>=0.9", "pyright>=1.1.390"]
|
|
23
|
+
|
|
24
|
+
[tool.setuptools.packages.find]
|
|
25
|
+
where = ["src"]
|
|
26
|
+
|
|
27
|
+
[tool.uv]
|
|
28
|
+
package = true
|
|
29
|
+
|
|
30
|
+
[tool.ruff]
|
|
31
|
+
line-length = 100
|
|
32
|
+
target-version = "py310"
|
|
33
|
+
src = ["src", "tests"]
|
|
34
|
+
|
|
35
|
+
[tool.ruff.lint]
|
|
36
|
+
select = [
|
|
37
|
+
"E", # pycodestyle
|
|
38
|
+
"F", # pyflakes
|
|
39
|
+
"I", # isort
|
|
40
|
+
"UP", # pyupgrade
|
|
41
|
+
"B", # bugbear
|
|
42
|
+
"SIM", # simplify
|
|
43
|
+
"RUF", # ruff 自身规则
|
|
44
|
+
"PL", # pylint 子集
|
|
45
|
+
"BLE", # 禁止 except Exception
|
|
46
|
+
]
|
|
47
|
+
ignore = ["PLR0913", "RUF001", "RUF002", "RUF003"]
|
|
48
|
+
|
|
49
|
+
[tool.ruff.lint.per-file-ignores]
|
|
50
|
+
"tests/*" = ["PLR2004"]
|
|
51
|
+
|
|
52
|
+
[tool.pyright]
|
|
53
|
+
include = ["src", "tests"]
|
|
54
|
+
pythonVersion = "3.10"
|
|
55
|
+
typeCheckingMode = "basic"
|
qsignt-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""qsignt —— QsigNT signer 注册工具。
|
|
2
|
+
|
|
3
|
+
上行 OpenPGP 加密,下行一次性 X25519 + AES-256-GCM。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .box import b64d, b64e, new_keypair, open_sealed, seal
|
|
7
|
+
from .proto import build_init, build_payload, parse_init, parse_payload
|
|
8
|
+
|
|
9
|
+
# 签名申请只在这一个群发放,其他地方不接。
|
|
10
|
+
COMMUNITY_GROUP = "1132696493"
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"COMMUNITY_GROUP",
|
|
14
|
+
"b64d",
|
|
15
|
+
"b64e",
|
|
16
|
+
"build_init",
|
|
17
|
+
"build_payload",
|
|
18
|
+
"new_keypair",
|
|
19
|
+
"open_sealed",
|
|
20
|
+
"parse_init",
|
|
21
|
+
"parse_payload",
|
|
22
|
+
"seal",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""下行信封:一次性 X25519 + AES-256-GCM。
|
|
2
|
+
|
|
3
|
+
上行用 OpenPGP(见 pgpx.py),下行只加密到客户端本次流程的一次性公钥。
|
|
4
|
+
公钥随上行 payload 一起提交,服务端用它对 JSON 做 AEAD 加密。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
15
|
+
from cryptography.hazmat.primitives.asymmetric.x25519 import (
|
|
16
|
+
X25519PrivateKey,
|
|
17
|
+
X25519PublicKey,
|
|
18
|
+
)
|
|
19
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
20
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
21
|
+
|
|
22
|
+
VERSION = 1
|
|
23
|
+
KEY_LEN = 32
|
|
24
|
+
NONCE_LEN = 12
|
|
25
|
+
TAG_LEN = 16
|
|
26
|
+
INFO = b"qsignt-open-v1"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def b64e(data: bytes) -> str:
|
|
30
|
+
return base64.urlsafe_b64encode(data).decode().rstrip("=")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def b64d(text: str) -> bytes:
|
|
34
|
+
text = "".join(text.split())
|
|
35
|
+
pad = "=" * (-len(text) % 4)
|
|
36
|
+
return base64.urlsafe_b64decode(text + pad)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def new_keypair() -> tuple[X25519PrivateKey, bytes]:
|
|
40
|
+
"""返回 (私钥, 32 字节公钥)。"""
|
|
41
|
+
priv = X25519PrivateKey.generate()
|
|
42
|
+
pub = priv.public_key().public_bytes(
|
|
43
|
+
encoding=serialization.Encoding.Raw,
|
|
44
|
+
format=serialization.PublicFormat.Raw,
|
|
45
|
+
)
|
|
46
|
+
return priv, pub
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _derive(shared: bytes, flow: str) -> bytes:
|
|
50
|
+
return HKDF(
|
|
51
|
+
algorithm=hashes.SHA256(),
|
|
52
|
+
length=32,
|
|
53
|
+
salt=flow.encode(),
|
|
54
|
+
info=INFO,
|
|
55
|
+
).derive(shared)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def seal(peer_pub_raw: bytes, flow: str, payload: dict[str, Any]) -> str:
|
|
59
|
+
"""服务端侧。返回 base64url(bot_eph_pub || nonce || ciphertext)。"""
|
|
60
|
+
if len(peer_pub_raw) != KEY_LEN:
|
|
61
|
+
raise ValueError("peer public key must be 32 bytes")
|
|
62
|
+
|
|
63
|
+
eph_priv, eph_pub = new_keypair()
|
|
64
|
+
shared = eph_priv.exchange(X25519PublicKey.from_public_bytes(peer_pub_raw))
|
|
65
|
+
key = _derive(shared, flow)
|
|
66
|
+
|
|
67
|
+
nonce = os.urandom(NONCE_LEN)
|
|
68
|
+
data = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode()
|
|
69
|
+
ct = AESGCM(key).encrypt(nonce, data, flow.encode())
|
|
70
|
+
|
|
71
|
+
return b64e(eph_pub + nonce + ct)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def open_sealed(blob: str, flow: str, eph_priv: X25519PrivateKey) -> dict[str, Any]:
|
|
75
|
+
"""客户端侧。blob 为下行那串 base64url。"""
|
|
76
|
+
raw = b64d(blob)
|
|
77
|
+
if len(raw) < 32 + NONCE_LEN + TAG_LEN:
|
|
78
|
+
raise ValueError("下行载荷长度不足")
|
|
79
|
+
|
|
80
|
+
bot_pub = raw[:32]
|
|
81
|
+
nonce = raw[32 : 32 + NONCE_LEN]
|
|
82
|
+
ct = raw[32 + NONCE_LEN :]
|
|
83
|
+
|
|
84
|
+
shared = eph_priv.exchange(X25519PublicKey.from_public_bytes(bot_pub))
|
|
85
|
+
key = _derive(shared, flow)
|
|
86
|
+
data = AESGCM(key).decrypt(nonce, ct, flow.encode())
|
|
87
|
+
return json.loads(data)
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""命令行入口。
|
|
2
|
+
|
|
3
|
+
人只做三件事:贴初始化消息、贴回包、把工具打印出的指令发回群里。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from . import COMMUNITY_GROUP, box, pgpx, proto
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def state_dir() -> Path:
|
|
18
|
+
root = os.environ.get("QSIGNT_STATE") or os.path.join(
|
|
19
|
+
os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config"), "qsignt"
|
|
20
|
+
)
|
|
21
|
+
p = Path(root)
|
|
22
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
os.chmod(p, 0o700)
|
|
24
|
+
return p
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _save(name: str, data: dict) -> None:
|
|
28
|
+
path = state_dir() / name
|
|
29
|
+
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
|
30
|
+
os.chmod(path, 0o600)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _load(name: str) -> dict | None:
|
|
34
|
+
path = state_dir() / name
|
|
35
|
+
if not path.exists():
|
|
36
|
+
return None
|
|
37
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def cmd_enroll(args: argparse.Namespace) -> int:
|
|
41
|
+
init = proto.parse_init(args.msg)
|
|
42
|
+
priv, pub = box.new_keypair()
|
|
43
|
+
payload = proto.build_payload(init.flow, pub)
|
|
44
|
+
ct = pgpx.encrypt_to(init.armor, payload)
|
|
45
|
+
|
|
46
|
+
_save(
|
|
47
|
+
"flow.json",
|
|
48
|
+
{
|
|
49
|
+
"flow": init.flow,
|
|
50
|
+
"eph": box.b64e(priv.private_bytes_raw()),
|
|
51
|
+
},
|
|
52
|
+
)
|
|
53
|
+
print("把下面这条整段发到群里:\n")
|
|
54
|
+
print(proto.submit_command(ct))
|
|
55
|
+
return 0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def cmd_open(args: argparse.Namespace) -> int:
|
|
59
|
+
st = _load("flow.json")
|
|
60
|
+
if not st:
|
|
61
|
+
print("没有进行中的流程,先跑 enroll", file=sys.stderr)
|
|
62
|
+
return 2
|
|
63
|
+
|
|
64
|
+
blob = args.msg.strip()
|
|
65
|
+
# 回执可能是合并转发里带说明的一整段,先切到指令本身
|
|
66
|
+
marker = "#qsignt key open"
|
|
67
|
+
if marker in blob:
|
|
68
|
+
blob = blob.split(marker, 1)[1]
|
|
69
|
+
elif "key open" in blob:
|
|
70
|
+
blob = blob.split("key open", 1)[1]
|
|
71
|
+
blob = "".join(blob.split())
|
|
72
|
+
|
|
73
|
+
priv = box.X25519PrivateKey.from_private_bytes(box.b64d(st["eph"]))
|
|
74
|
+
data = box.open_sealed(blob, st["flow"], priv)
|
|
75
|
+
|
|
76
|
+
if data.get("flow") != st["flow"]:
|
|
77
|
+
print("回包的 flow 和本地不一致,丢弃", file=sys.stderr)
|
|
78
|
+
return 3
|
|
79
|
+
|
|
80
|
+
_save(
|
|
81
|
+
"token.json",
|
|
82
|
+
{
|
|
83
|
+
"apikey": data["apikey"],
|
|
84
|
+
"flow": st["flow"],
|
|
85
|
+
"exp": data.get("exp"),
|
|
86
|
+
"state": "pending",
|
|
87
|
+
},
|
|
88
|
+
)
|
|
89
|
+
print(f"apikey 已保存到 {state_dir() / 'token.json'}\n")
|
|
90
|
+
print("把下面这条发到群里完成激活:\n")
|
|
91
|
+
print(proto.verify_command(data["chall"]))
|
|
92
|
+
return 0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def cmd_show(args: argparse.Namespace) -> int:
|
|
96
|
+
tok = _load("token.json")
|
|
97
|
+
if not tok:
|
|
98
|
+
print("本地没有 apikey", file=sys.stderr)
|
|
99
|
+
return 2
|
|
100
|
+
print(tok["apikey"])
|
|
101
|
+
return 0
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def cmd_keygen(args: argparse.Namespace) -> int:
|
|
105
|
+
home = Path(args.out).expanduser()
|
|
106
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
os.chmod(home, 0o700)
|
|
108
|
+
fpr, armor = pgpx.generate(str(home), args.identity)
|
|
109
|
+
print(f"指纹 {fpr}\n")
|
|
110
|
+
print(armor)
|
|
111
|
+
return 0
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def cmd_decrypt(args: argparse.Namespace) -> int:
|
|
115
|
+
raw = pgpx.decrypt_with(args.home, args.msg)
|
|
116
|
+
print(json.dumps(proto.parse_payload(raw), ensure_ascii=False))
|
|
117
|
+
return 0
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def cmd_seal(args: argparse.Namespace) -> int:
|
|
121
|
+
payload = json.loads(Path(args.json).read_text(encoding="utf-8"))
|
|
122
|
+
print(box.seal(box.b64d(args.eph), args.flow, payload))
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def cmd_community(args: argparse.Namespace) -> int:
|
|
127
|
+
"""固定输出签名申请群,不接受参数。"""
|
|
128
|
+
print("签名申请群")
|
|
129
|
+
print(COMMUNITY_GROUP)
|
|
130
|
+
print()
|
|
131
|
+
print("进群后发 #qsignt init 开始注册")
|
|
132
|
+
return 0
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
136
|
+
p = argparse.ArgumentParser(prog="qsignt", description="QsigNT signer 注册工具")
|
|
137
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
138
|
+
|
|
139
|
+
e = sub.add_parser("enroll", help="用群里的初始化消息生成提交指令")
|
|
140
|
+
e.add_argument("--msg", required=True, help="整段初始化消息(含 flow 与公钥)")
|
|
141
|
+
e.set_defaults(func=cmd_enroll)
|
|
142
|
+
|
|
143
|
+
o = sub.add_parser("open", help="解开 bot 回包,取出 apikey")
|
|
144
|
+
o.add_argument("--msg", required=True, help="bot 回的 key open 那条")
|
|
145
|
+
o.set_defaults(func=cmd_open)
|
|
146
|
+
|
|
147
|
+
s = sub.add_parser("show", help="打印本地 apikey")
|
|
148
|
+
s.set_defaults(func=cmd_show)
|
|
149
|
+
|
|
150
|
+
k = sub.add_parser("keygen", help="生成 bot 侧 OpenPGP 密钥对")
|
|
151
|
+
k.add_argument("--out", required=True)
|
|
152
|
+
k.add_argument("--identity", default="qsignt-bot")
|
|
153
|
+
k.set_defaults(func=cmd_keygen)
|
|
154
|
+
|
|
155
|
+
d = sub.add_parser("decrypt", help="服务端解密上行 payload")
|
|
156
|
+
d.add_argument("--home", required=True, help="GNUPGHOME")
|
|
157
|
+
d.add_argument("--msg", required=True, help="armor 密文")
|
|
158
|
+
d.set_defaults(func=cmd_decrypt)
|
|
159
|
+
|
|
160
|
+
sl = sub.add_parser("seal", help="服务端生成下行密文")
|
|
161
|
+
sl.add_argument("--eph", required=True, help="客户端一次性公钥 base64url")
|
|
162
|
+
sl.add_argument("--flow", required=True)
|
|
163
|
+
sl.add_argument("--json", required=True, help="待加密 JSON 文件路径")
|
|
164
|
+
sl.set_defaults(func=cmd_seal)
|
|
165
|
+
|
|
166
|
+
cm = sub.add_parser("community", help="显示签名申请群号")
|
|
167
|
+
cm.set_defaults(func=cmd_community)
|
|
168
|
+
|
|
169
|
+
return p
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def main(argv: list[str] | None = None) -> int:
|
|
173
|
+
args = build_parser().parse_args(argv)
|
|
174
|
+
try:
|
|
175
|
+
return args.func(args)
|
|
176
|
+
except (ValueError, pgpx.GPGError) as exc:
|
|
177
|
+
print(f"失败:{exc}", file=sys.stderr)
|
|
178
|
+
return 1
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
if __name__ == "__main__":
|
|
182
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""gpg 命令行薄封装。
|
|
2
|
+
|
|
3
|
+
不重写 OpenPGP。理由很实际:RFC 4880 的 ECDH 加密要手工拼 MPI、KDF 参数和
|
|
4
|
+
AES key wrap,自己写等于给协议开新坑;gpg 是现成的、经过审计的实现。
|
|
5
|
+
|
|
6
|
+
前提:本机有 gpg 2.3 以上。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import tempfile
|
|
15
|
+
from collections.abc import Iterator
|
|
16
|
+
from contextlib import contextmanager
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
GPG = shutil.which("gpg") or shutil.which("gpg2")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class GPGError(RuntimeError):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _require() -> str:
|
|
27
|
+
if not GPG:
|
|
28
|
+
raise GPGError("找不到 gpg,请先安装 gnupg 2.3+")
|
|
29
|
+
return GPG
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _run(args: list[str], homedir: str, stdin: bytes | None = None) -> bytes:
|
|
33
|
+
env = dict(os.environ)
|
|
34
|
+
env["GNUPGHOME"] = homedir
|
|
35
|
+
env.pop("GPG_AGENT_INFO", None)
|
|
36
|
+
proc = subprocess.run(
|
|
37
|
+
[_require(), "--homedir", homedir, "--batch", "--yes", "--no-tty", *args],
|
|
38
|
+
input=stdin,
|
|
39
|
+
capture_output=True,
|
|
40
|
+
env=env,
|
|
41
|
+
timeout=120,
|
|
42
|
+
check=False,
|
|
43
|
+
)
|
|
44
|
+
if proc.returncode != 0:
|
|
45
|
+
msg = proc.stderr.decode(errors="replace").strip()
|
|
46
|
+
raise GPGError(msg or f"gpg 退出码 {proc.returncode}")
|
|
47
|
+
return proc.stdout
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@contextmanager
|
|
51
|
+
def homedir(path: str | Path | None = None) -> Iterator[str]:
|
|
52
|
+
"""临时 GNUPGHOME,权限 0700。"""
|
|
53
|
+
if path is not None:
|
|
54
|
+
p = Path(path)
|
|
55
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
os.chmod(p, 0o700)
|
|
57
|
+
yield str(p)
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
with tempfile.TemporaryDirectory(prefix="qsignt-gpg-") as tmp:
|
|
61
|
+
os.chmod(tmp, 0o700)
|
|
62
|
+
yield tmp
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _fingerprints(homedir_path: str) -> list[str]:
|
|
66
|
+
out = _run(["--with-colons", "--list-keys"], homedir_path).decode(errors="replace")
|
|
67
|
+
fprs: list[str] = []
|
|
68
|
+
want = False
|
|
69
|
+
for line in out.splitlines():
|
|
70
|
+
fields = line.split(":")
|
|
71
|
+
if fields[0] in ("pub", "sub"):
|
|
72
|
+
want = True
|
|
73
|
+
elif fields[0] == "fpr" and want:
|
|
74
|
+
fprs.append(fields[9])
|
|
75
|
+
want = False
|
|
76
|
+
return fprs
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def generate(
|
|
80
|
+
homedir_path: str, identity: str = "qsignt-bot", expire: str = "2y"
|
|
81
|
+
) -> tuple[str, str]:
|
|
82
|
+
"""生成 Ed25519 主钥 + cv25519 加密子钥,返回 (指纹, armored 公钥)。"""
|
|
83
|
+
_run(
|
|
84
|
+
[
|
|
85
|
+
"--passphrase",
|
|
86
|
+
"",
|
|
87
|
+
"--quick-generate-key",
|
|
88
|
+
identity,
|
|
89
|
+
"ed25519",
|
|
90
|
+
"sign",
|
|
91
|
+
expire,
|
|
92
|
+
],
|
|
93
|
+
homedir_path,
|
|
94
|
+
)
|
|
95
|
+
fprs = _fingerprints(homedir_path)
|
|
96
|
+
if not fprs:
|
|
97
|
+
raise GPGError("生成主钥后拿不到指纹")
|
|
98
|
+
main = fprs[0]
|
|
99
|
+
|
|
100
|
+
_run(
|
|
101
|
+
["--passphrase", "", "--quick-add-key", main, "cv25519", "encr", expire],
|
|
102
|
+
homedir_path,
|
|
103
|
+
)
|
|
104
|
+
armor = export_public(homedir_path, main)
|
|
105
|
+
return main, armor
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def export_public(homedir_path: str, fpr: str | None = None) -> str:
|
|
109
|
+
args = ["--armor", "--export"]
|
|
110
|
+
if fpr:
|
|
111
|
+
args.append(fpr)
|
|
112
|
+
return _run(args, homedir_path).decode()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def import_public(homedir_path: str, armored: str) -> str:
|
|
116
|
+
_run(["--import"], homedir_path, stdin=armored.encode())
|
|
117
|
+
fprs = _fingerprints(homedir_path)
|
|
118
|
+
if not fprs:
|
|
119
|
+
raise GPGError("导入公钥后拿不到指纹")
|
|
120
|
+
return fprs[0]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def encrypt_to(armored_pub: str, plaintext: bytes) -> str:
|
|
124
|
+
"""用一段 armor 公钥加密,返回 armor 密文。"""
|
|
125
|
+
with homedir() as tmp:
|
|
126
|
+
fpr = import_public(tmp, armored_pub)
|
|
127
|
+
out = _run(
|
|
128
|
+
["--armor", "--trust-model", "always", "--recipient", fpr, "--encrypt"],
|
|
129
|
+
tmp,
|
|
130
|
+
stdin=plaintext,
|
|
131
|
+
)
|
|
132
|
+
return out.decode()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def decrypt_with(homedir_path: str, armored_ct: str) -> bytes:
|
|
136
|
+
return _run(["--decrypt"], homedir_path, stdin=armored_ct.encode())
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def parse_armor(text: str) -> str:
|
|
140
|
+
"""从一段混杂文本里抽出第一个 armor 公钥块。"""
|
|
141
|
+
begin = "-----BEGIN PGP PUBLIC KEY BLOCK-----"
|
|
142
|
+
end = "-----END PGP PUBLIC KEY BLOCK-----"
|
|
143
|
+
start = text.find(begin)
|
|
144
|
+
if start < 0:
|
|
145
|
+
raise ValueError("文本里没有 PGP 公钥块")
|
|
146
|
+
stop = text.find(end, start)
|
|
147
|
+
if stop < 0:
|
|
148
|
+
raise ValueError("公钥块没有结束标记")
|
|
149
|
+
return text[start : stop + len(end)]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""协议里的文本形态:初始化消息、上行 payload、指令拼装。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
from . import pgpx
|
|
11
|
+
from .box import b64e
|
|
12
|
+
|
|
13
|
+
HEADER = "SIGNER-KEY-V1"
|
|
14
|
+
FLOW_RE = re.compile(r"flow=([0-9A-Za-z]{4,16})")
|
|
15
|
+
EXP_RE = re.compile(r"exp=(\d+)\s*(m|min|分钟)?")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class InitMessage:
|
|
20
|
+
flow: str
|
|
21
|
+
armor: str
|
|
22
|
+
expire_minutes: int | None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_init(flow: str, armored_pub: str, expire_minutes: int = 10) -> str:
|
|
26
|
+
return f"{HEADER} flow={flow} exp={expire_minutes}m\n{armored_pub.strip()}\n"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parse_init(text: str) -> InitMessage:
|
|
30
|
+
m = FLOW_RE.search(text)
|
|
31
|
+
if not m:
|
|
32
|
+
raise ValueError("初始化消息里没有 flow 标识")
|
|
33
|
+
e = EXP_RE.search(text)
|
|
34
|
+
return InitMessage(
|
|
35
|
+
flow=m.group(1),
|
|
36
|
+
armor=pgpx.parse_armor(text),
|
|
37
|
+
expire_minutes=int(e.group(1)) if e else None,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_payload(flow: str, eph_pub: bytes, kind: str = "enroll") -> bytes:
|
|
42
|
+
return json.dumps(
|
|
43
|
+
{
|
|
44
|
+
"v": 1,
|
|
45
|
+
"kind": kind,
|
|
46
|
+
"flow": flow,
|
|
47
|
+
"eph": b64e(eph_pub),
|
|
48
|
+
"ts": int(time.time()),
|
|
49
|
+
},
|
|
50
|
+
separators=(",", ":"),
|
|
51
|
+
).encode()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def parse_payload(raw: bytes) -> dict:
|
|
55
|
+
data = json.loads(raw)
|
|
56
|
+
if data.get("v") != 1:
|
|
57
|
+
raise ValueError("payload 版本不认识")
|
|
58
|
+
for key in ("flow", "eph"):
|
|
59
|
+
if key not in data:
|
|
60
|
+
raise ValueError(f"payload 缺少 {key}")
|
|
61
|
+
return data
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def submit_command(armored_ct: str) -> str:
|
|
65
|
+
return "#qsignt key submit " + armored_ct.strip()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def open_command(blob: str) -> str:
|
|
69
|
+
return "#qsignt key open " + blob.strip()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def verify_command(chall: str) -> str:
|
|
73
|
+
return "#qsignt verify " + chall.strip()
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: qsignt
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: QsigNT signer 注册工具:OpenPGP 上行 + X25519 下行
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Repository, https://github.com/AmritaBot/qsignt.py
|
|
7
|
+
Project-URL: Issues, https://github.com/AmritaBot/qsignt.py/issues
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: cryptography>=42
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# qsignt
|
|
15
|
+
|
|
16
|
+
[](https://github.com/AmritaBot/qsignt.py/actions/workflows/ci.yml)
|
|
17
|
+
|
|
18
|
+
QsigNT signer 的注册工具。在 QQ 群里跑一遍流程,把 apikey 换到本地。
|
|
19
|
+
|
|
20
|
+
依赖:Python 3.10+、gnupg 2.3+(需要 `gpg` 在 PATH 里)。
|
|
21
|
+
|
|
22
|
+
## 签名申请入口
|
|
23
|
+
|
|
24
|
+
签名权限只在 QQ 群 **1132696493** 发放,进群后直接发 `#qsignt init` 就能开始,整个
|
|
25
|
+
流程在群里闭环,不需要单独找人。
|
|
26
|
+
|
|
27
|
+
群号固定写死在工具里,查一下就知道:
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
qsignt community
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
(不联网、不读配置,纯粹回显常量,防的是手抄错号。)
|
|
34
|
+
|
|
35
|
+
apikey 是组织层面的凭证,按用户发放。仓库这边不发 key、也不提供自助领取,抱着
|
|
36
|
+
clone 下来就跑出权限的期待会落空。
|
|
37
|
+
|
|
38
|
+
能拿到的东西:
|
|
39
|
+
|
|
40
|
+
- 一个 `qsig_` 开头的 apikey,由 QsigNT daemon 校验,请求头带 `Authorization: Bearer <apikey>`
|
|
41
|
+
- apikey 与用户一一对应,一人一把,不做共享
|
|
42
|
+
- 限速按用户计,默认 20 请求/秒,超了返回 429 和 `Retry-After`
|
|
43
|
+
|
|
44
|
+
注册流程只在群里走,私聊不办理。群聊之外的旁路申请一律不接,包括找维护者单独要。
|
|
45
|
+
|
|
46
|
+
## 为什么是 OpenPGP
|
|
47
|
+
|
|
48
|
+
QQ 客户端与服务端的身份已由平台担保,需要防的只是群里旁观者顺手读到凭证。
|
|
49
|
+
上行用服务端公钥加密就够了,不需要证书链,也不需要指纹交叉验证。
|
|
50
|
+
|
|
51
|
+
下行不用 OpenPGP,改用一次性 X25519 + AES-256-GCM:客户端在提交时带上本次流程
|
|
52
|
+
的临时公钥,服务端加密回包即可。少一次密钥交换,载荷也短一半。
|
|
53
|
+
|
|
54
|
+
## 流程
|
|
55
|
+
|
|
56
|
+
群里发 `#qsignt init`,机器人回一条带公钥的消息。整段复制,然后:
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
qsignt enroll --msg "SIGNER-KEY-V1 flow=AB12CD34 exp=10m
|
|
60
|
+
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
|
61
|
+
...
|
|
62
|
+
-----END PGP PUBLIC KEY BLOCK-----"
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
工具打印一条 `#qsignt key submit ...`,发到群里。
|
|
66
|
+
|
|
67
|
+
机器人回 `#qsignt key open ...`,复制后:
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
qsignt open --msg "#qsignt key open AbCd..."
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
工具把 apikey 存到 `~/.config/qsignt/token.json`(权限 0600),并打印
|
|
74
|
+
`#qsignt verify XXXXXXXX`。发出去,激活完成。
|
|
75
|
+
|
|
76
|
+
`qsignt show` 随时取回 apikey。
|
|
77
|
+
|
|
78
|
+
## 服务端
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
qsignt keygen --out /var/lib/qsignt/gpg # 生成 bot 密钥对,公钥贴到群里
|
|
82
|
+
qsignt decrypt --home /var/lib/qsignt/gpg --msg "..."
|
|
83
|
+
qsignt seal --eph <客户端公钥> --flow AB12CD34 --json reply.json
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## 边界
|
|
87
|
+
|
|
88
|
+
- 中间中断不会白费:流程状态存在本地,`open` 之前重跑 `enroll` 会重开新流程。
|
|
89
|
+
- 群消息会被折叠,复制时可能带上换行和空格,解析时已做容错。
|
|
90
|
+
- apikey 明文只落在本地文件里,工具不做上传、不做同步。
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/qsignt/__init__.py
|
|
5
|
+
src/qsignt/box.py
|
|
6
|
+
src/qsignt/cli.py
|
|
7
|
+
src/qsignt/pgpx.py
|
|
8
|
+
src/qsignt/proto.py
|
|
9
|
+
src/qsignt.egg-info/PKG-INFO
|
|
10
|
+
src/qsignt.egg-info/SOURCES.txt
|
|
11
|
+
src/qsignt.egg-info/dependency_links.txt
|
|
12
|
+
src/qsignt.egg-info/entry_points.txt
|
|
13
|
+
src/qsignt.egg-info/requires.txt
|
|
14
|
+
src/qsignt.egg-info/top_level.txt
|
|
15
|
+
tests/test_proto.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cryptography>=42
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
qsignt
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from cryptography.exceptions import InvalidTag
|
|
5
|
+
|
|
6
|
+
from qsignt import box, proto
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_seal_open_roundtrip():
|
|
10
|
+
priv, pub = box.new_keypair()
|
|
11
|
+
payload = {"v": 1, "flow": "AB12CD34", "apikey": "qsig_test", "chall": "ZZZZ0000"}
|
|
12
|
+
blob = box.seal(pub, "AB12CD34", payload)
|
|
13
|
+
out = box.open_sealed(blob, "AB12CD34", priv)
|
|
14
|
+
assert out == payload
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_flow_binding_mismatch():
|
|
18
|
+
priv, pub = box.new_keypair()
|
|
19
|
+
blob = box.seal(pub, "AB12CD34", {"v": 1})
|
|
20
|
+
with pytest.raises(InvalidTag):
|
|
21
|
+
box.open_sealed(blob, "OTHERFLOW", priv)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_parse_init():
|
|
25
|
+
armor = "-----BEGIN PGP PUBLIC KEY BLOCK-----\nxx\n-----END PGP PUBLIC KEY BLOCK-----"
|
|
26
|
+
msg = proto.build_init("AB12CD34", armor, 10)
|
|
27
|
+
got = proto.parse_init(msg)
|
|
28
|
+
assert got.flow == "AB12CD34"
|
|
29
|
+
assert got.expire_minutes == 10
|
|
30
|
+
assert "BEGIN PGP PUBLIC KEY" in got.armor
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_payload_roundtrip():
|
|
34
|
+
raw = proto.build_payload("AB12CD34", b"\x01" * 32)
|
|
35
|
+
data = proto.parse_payload(raw)
|
|
36
|
+
assert data["flow"] == "AB12CD34"
|
|
37
|
+
assert len(box.b64d(data["eph"])) == 32
|
|
38
|
+
assert json.loads(raw)["kind"] == "enroll"
|