cloudiamond-scratch 0.1.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.
- cloudiamond_scratch/__init__.py +7 -0
- cloudiamond_scratch/__main__.py +5 -0
- cloudiamond_scratch/bridge.py +84 -0
- cloudiamond_scratch/cli.py +113 -0
- cloudiamond_scratch/py.typed +0 -0
- cloudiamond_scratch/reference/__init__.py +1 -0
- cloudiamond_scratch/reference/client.py +113 -0
- cloudiamond_scratch/reference/prims.py +378 -0
- cloudiamond_scratch/scratch/__init__.py +0 -0
- cloudiamond_scratch/scratch/build.py +97 -0
- cloudiamond_scratch/scratch/program.py +1030 -0
- cloudiamond_scratch/scratch/spy.py +694 -0
- cloudiamond_scratch/scratch/spyrt.py +140 -0
- cloudiamond_scratch/selftest.py +125 -0
- cloudiamond_scratch/server.py +288 -0
- cloudiamond_scratch-0.1.0.dist-info/METADATA +191 -0
- cloudiamond_scratch-0.1.0.dist-info/RECORD +21 -0
- cloudiamond_scratch-0.1.0.dist-info/WHEEL +5 -0
- cloudiamond_scratch-0.1.0.dist-info/entry_points.txt +2 -0
- cloudiamond_scratch-0.1.0.dist-info/licenses/LICENSE +21 -0
- cloudiamond_scratch-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""cloudiamond-scratch: Scratch <-> Python の暗号通信(X25519 + ChaCha20-Poly1305)。"""
|
|
2
|
+
__version__ = "0.1.0"
|
|
3
|
+
|
|
4
|
+
from .bridge import CloudBridge # noqa: E402
|
|
5
|
+
from .server import SecureServer, load_or_create_static_key, public_bytes # noqa: E402
|
|
6
|
+
|
|
7
|
+
__all__ = ["CloudBridge", "SecureServer", "load_or_create_static_key", "public_bytes", "__version__"]
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""
|
|
2
|
+
bridge.py -- クラウド変数(Scratch / TurboWarp)と SecureServer をつなぐ、通信手段に依存しない橋渡し。
|
|
3
|
+
|
|
4
|
+
CloudBridge は WebSocket も scratchattach も知らない。使う側が次の 2 つだけ用意する。
|
|
5
|
+
on_variable(name, value) クラウド変数が更新されたら呼ぶ(受信)
|
|
6
|
+
write(name, value) クラウド変数を書く関数を渡す(送信。slot を全部書いた「後で」HDR を書く)
|
|
7
|
+
|
|
8
|
+
Scratch 側(scratch/program.py)との約束:
|
|
9
|
+
C2S = クライアント -> サーバー: "☁ C2S_0".."☁ C2S_3"(slot) と "☁ C2S_HDR"(コミット)
|
|
10
|
+
S2C = サーバー -> クライアント: "☁ S2C_0".."☁ S2C_3" と "☁ S2C_HDR"
|
|
11
|
+
HDR の値が「変わった」ことが「slot が揃った」合図。だから HDR は書くたびに必ず変わる(seq を増やす)。
|
|
12
|
+
seq はサーバー全体で単調増加させる。セッションごとに 1 から数え直すと、新しい接続の最初の返信の HDR が
|
|
13
|
+
前の接続の最後の HDR と同じ値になり、クライアントが「変化なし」と見なしてしまう。
|
|
14
|
+
|
|
15
|
+
安全性: クラウド変数は誰でも書ける。壊れた値・他人の値は暗号(AEAD)の検証で必ず捨てられる(= 安全側)。
|
|
16
|
+
捨てられるだけで届かなくはできる(妨害は防げない)。
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import random
|
|
21
|
+
import threading
|
|
22
|
+
from typing import Callable, Dict, List, Optional
|
|
23
|
+
|
|
24
|
+
from .server import MAX_SLOTS, SecureServer, from_slots, to_slots
|
|
25
|
+
|
|
26
|
+
C2S_HDR = "☁ C2S_HDR"
|
|
27
|
+
C2S_SLOTS = ["☁ C2S_%d" % i for i in range(MAX_SLOTS)]
|
|
28
|
+
S2C_HDR = "☁ S2C_HDR"
|
|
29
|
+
S2C_SLOTS = ["☁ S2C_%d" % i for i in range(MAX_SLOTS)]
|
|
30
|
+
CLOUD_VARIABLES = [C2S_HDR] + C2S_SLOTS + [S2C_HDR] + S2C_SLOTS # プロジェクトに必要な 10 個
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CloudBridge:
|
|
34
|
+
def __init__(self, server: SecureServer, write: Callable[[str, str], None],
|
|
35
|
+
seq_start: Optional[int] = None):
|
|
36
|
+
self.server = server
|
|
37
|
+
self._write = write
|
|
38
|
+
self._values: Dict[str, str] = {}
|
|
39
|
+
self._seq = seq_start if seq_start is not None else random.SystemRandom().randrange(1, 900000)
|
|
40
|
+
self._lock = threading.Lock()
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------- 受信
|
|
43
|
+
def on_variable(self, name: str, value) -> None:
|
|
44
|
+
"""クラウド変数の更新を 1 件渡す。名前が C2S_* 以外なら無視する。"""
|
|
45
|
+
if name not in C2S_SLOTS and name != C2S_HDR:
|
|
46
|
+
return
|
|
47
|
+
with self._lock:
|
|
48
|
+
self._values[name] = str(value)
|
|
49
|
+
if name != C2S_HDR:
|
|
50
|
+
return
|
|
51
|
+
# 同じ HDR を「重複」として捨てない: 別クライアントの正規フレームが偶然同じ HDR 値になり得るため。
|
|
52
|
+
# 再接続時の現在値の再送などで同じフレームが再度来ても、サーバーのリプレイ防止
|
|
53
|
+
# (既存 sid の HELLO・古いカウンタは拒否)が無害化する。
|
|
54
|
+
frames = self._decode(self._values[C2S_HDR])
|
|
55
|
+
if frames is None:
|
|
56
|
+
return
|
|
57
|
+
for reply in self.server.handle_frame(frames):
|
|
58
|
+
self._emit(reply)
|
|
59
|
+
|
|
60
|
+
def _decode(self, hdr: str) -> Optional[bytes]:
|
|
61
|
+
try:
|
|
62
|
+
n = int(hdr[-2:])
|
|
63
|
+
slots = [self._values[C2S_SLOTS[i]] for i in range(n)]
|
|
64
|
+
return from_slots(hdr, slots)[1]
|
|
65
|
+
except (ValueError, KeyError, IndexError):
|
|
66
|
+
return None # 壊れた値は黙って捨てる
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------- 送信
|
|
69
|
+
def push(self, sid: bytes, data: bytes) -> bool:
|
|
70
|
+
"""サーバー起点でクライアントへ送る(セッションが無い・長すぎる場合は False)。"""
|
|
71
|
+
frame = self.server.send(sid, data)
|
|
72
|
+
if frame is None:
|
|
73
|
+
return False
|
|
74
|
+
self._emit(frame)
|
|
75
|
+
return True
|
|
76
|
+
|
|
77
|
+
def _emit(self, frame: bytes) -> None:
|
|
78
|
+
with self._lock:
|
|
79
|
+
self._seq = self._seq % 999999 + 1 # 1..999999 を巡回。毎回必ず変わる
|
|
80
|
+
seq = self._seq
|
|
81
|
+
slots, hdr = to_slots(frame, seq)
|
|
82
|
+
for i, s in enumerate(slots):
|
|
83
|
+
self._write(S2C_SLOTS[i], s)
|
|
84
|
+
self._write(S2C_HDR, hdr) # HDR を最後に書く = コミット
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cli.py -- コマンドライン: cloudiamond-scratch (または python -m cloudiamond_scratch)
|
|
3
|
+
|
|
4
|
+
keygen server.key サーバーの長期鍵を作る(0600)。秘密鍵は公開しない。公開鍵が表示される
|
|
5
|
+
pubkey server.key 既存の秘密鍵から公開鍵を表示する
|
|
6
|
+
build --key server.key -o client.sb3
|
|
7
|
+
--pub <64桁の16進> Scratch クライアント(.sb3)を生成する。埋め込まれるのは公開鍵だけ
|
|
8
|
+
selftest 標準実装との照合(RFC ベクトル)と、クライアント/サーバーの往復を自己診断する
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
import zipfile
|
|
16
|
+
from typing import List, Optional
|
|
17
|
+
|
|
18
|
+
from . import __version__
|
|
19
|
+
from .server import load_or_create_static_key, public_bytes, to_digits
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _read_pub(args) -> bytes:
|
|
23
|
+
if args.pub:
|
|
24
|
+
h = args.pub.strip().lower().removeprefix("0x")
|
|
25
|
+
try:
|
|
26
|
+
raw = bytes.fromhex(h)
|
|
27
|
+
except ValueError:
|
|
28
|
+
raise SystemExit("error: --pub は 64 桁の 16 進数です")
|
|
29
|
+
if len(raw) != 32:
|
|
30
|
+
raise SystemExit("error: --pub は 32 byte(64 桁の 16 進数)である必要があります")
|
|
31
|
+
if not any(raw):
|
|
32
|
+
raise SystemExit("error: 全ゼロの公開鍵は使えません")
|
|
33
|
+
return raw
|
|
34
|
+
if args.key:
|
|
35
|
+
if not os.path.exists(args.key):
|
|
36
|
+
raise SystemExit("error: 鍵ファイルがありません: %s(先に keygen してください)" % args.key)
|
|
37
|
+
return public_bytes(load_or_create_static_key(args.key))
|
|
38
|
+
raise SystemExit("error: --key か --pub のどちらかを指定してください")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def cmd_keygen(args) -> int:
|
|
42
|
+
existed = os.path.exists(args.path)
|
|
43
|
+
key = load_or_create_static_key(args.path)
|
|
44
|
+
pub = public_bytes(key)
|
|
45
|
+
print(("既存の" if existed else "新しい") + "サーバー秘密鍵: %s ← 絶対に公開・コミットしない" % args.path)
|
|
46
|
+
_print_pub(pub)
|
|
47
|
+
return 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _print_pub(pub: bytes) -> None:
|
|
51
|
+
print("公開鍵(hex) :", pub.hex())
|
|
52
|
+
print("公開鍵(数字列) :", to_digits(pub))
|
|
53
|
+
print("→ 公開鍵は Scratch に埋め込んで構いません(公開情報)。`build` が自動で埋め込みます。")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def cmd_pubkey(args) -> int:
|
|
57
|
+
if not os.path.exists(args.path):
|
|
58
|
+
raise SystemExit("error: 鍵ファイルがありません: %s" % args.path)
|
|
59
|
+
_print_pub(public_bytes(load_or_create_static_key(args.path)))
|
|
60
|
+
return 0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def cmd_build(args) -> int:
|
|
64
|
+
from .scratch.build import build_client_sb3
|
|
65
|
+
pub = _read_pub(args)
|
|
66
|
+
data = build_client_sb3(pub, sprite_name=args.name)
|
|
67
|
+
with open(args.output, "wb") as f:
|
|
68
|
+
f.write(data)
|
|
69
|
+
with zipfile.ZipFile(args.output) as z:
|
|
70
|
+
assert z.testzip() is None
|
|
71
|
+
print("生成しました: %s (%.1f KiB)" % (args.output, len(data) / 1024))
|
|
72
|
+
print("サーバー公開鍵 :", pub.hex())
|
|
73
|
+
print("次の手順 : Scratch / TurboWarp で開き、クラウド変数の接続先をサーバーに向ける(README 参照)")
|
|
74
|
+
print("保存の前に : 実行した後のプロジェクトを保存・公開する前に『🔒 wipe secrets』ブロックを必ず実行する")
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def cmd_selftest(args) -> int:
|
|
79
|
+
from .selftest import run
|
|
80
|
+
return run(verbose=True)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
84
|
+
p = argparse.ArgumentParser(prog="cloudiamond-scratch",
|
|
85
|
+
description="Scratch <-> Python の暗号通信(X25519 + ChaCha20-Poly1305)")
|
|
86
|
+
p.add_argument("--version", action="version", version="cloudiamond-scratch " + __version__)
|
|
87
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
88
|
+
|
|
89
|
+
s = sub.add_parser("keygen", help="サーバーの長期鍵を作る")
|
|
90
|
+
s.add_argument("path", help="秘密鍵の保存先(例: server.key)")
|
|
91
|
+
s.set_defaults(fn=cmd_keygen)
|
|
92
|
+
|
|
93
|
+
s = sub.add_parser("pubkey", help="秘密鍵ファイルから公開鍵を表示する")
|
|
94
|
+
s.add_argument("path")
|
|
95
|
+
s.set_defaults(fn=cmd_pubkey)
|
|
96
|
+
|
|
97
|
+
s = sub.add_parser("build", help="Scratch クライアント(.sb3)を生成する")
|
|
98
|
+
g = s.add_mutually_exclusive_group(required=True)
|
|
99
|
+
g.add_argument("--key", help="サーバーの秘密鍵ファイル(公開鍵だけを取り出して埋め込む)")
|
|
100
|
+
g.add_argument("--pub", help="サーバーの公開鍵(64 桁の 16 進数)")
|
|
101
|
+
s.add_argument("-o", "--output", default="cloudiamond-client.sb3")
|
|
102
|
+
s.add_argument("--name", default="Cloudiamond", help="スプライト名")
|
|
103
|
+
s.set_defaults(fn=cmd_build)
|
|
104
|
+
|
|
105
|
+
s = sub.add_parser("selftest", help="自己診断")
|
|
106
|
+
s.set_defaults(fn=cmd_selftest)
|
|
107
|
+
|
|
108
|
+
args = p.parse_args(argv)
|
|
109
|
+
return args.fn(args)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
if __name__ == "__main__":
|
|
113
|
+
sys.exit(main())
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Scratch の制約内で書いた参照実装(テストと、移植元の仕様書)。"""
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cloudiamond_scratch.reference.client -- Scratch クライアントの参照実装(= 移植元の仕様書)
|
|
3
|
+
|
|
4
|
+
ここで使っているのは cloudiamond_scratch.reference.prims の関数と「リスト・数値・if・ループ」だけ。
|
|
5
|
+
バイト列は Python の list[int](0..255)で扱う = Scratch のリストそのもの。
|
|
6
|
+
各メソッドが、Scratch の「定義 ◯◯」カスタムブロック(実行中に画面を再描画しない)に1対1で対応する。
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import List, Optional
|
|
11
|
+
|
|
12
|
+
from . import prims as sp
|
|
13
|
+
|
|
14
|
+
T_HELLO, T_REPLY, T_C2S, T_S2C = 1, 2, 3, 4
|
|
15
|
+
LABEL_MASTER = list(b"SCRSEC1-master\0\0")
|
|
16
|
+
LABEL_C2S = list(b"SCRSEC1-c2s\0\0\0\0\0")
|
|
17
|
+
LABEL_S2C = list(b"SCRSEC1-s2c\0\0\0\0\0")
|
|
18
|
+
READY = list(b"SCRSEC1-READY\0\0\0")
|
|
19
|
+
PAD_BUCKET = 32
|
|
20
|
+
MAX_REPLY_TRIES = 3 # 偽の返信を大量に送り付けられても計算を無限にやらない
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def mix_entropy(*streams: List[int]) -> List[int]:
|
|
24
|
+
"""複数の乱数源を byte ごとに足して mod 256。どれか1つが予測不能なら結果も予測不能。
|
|
25
|
+
Scratch では: [pick random 0〜255] + [timer と mouse x/y の細かい桁から作った byte] + [ユーザーが
|
|
26
|
+
3 秒間マウスを振った軌跡] + [days since 2000 の小数下位桁] ... を足し合わせる。"""
|
|
27
|
+
return [sum(s[i] for s in streams) % 256 for i in range(32)]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _all_zero(v) -> bool:
|
|
31
|
+
return sum(v) == 0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _ctr6(n) -> List[int]:
|
|
35
|
+
return [n // 256 ** (5 - i) % 256 for i in range(6)]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _pad(data: List[int]) -> List[int]:
|
|
39
|
+
n = len(data)
|
|
40
|
+
body = [n // 256, n % 256] + list(data)
|
|
41
|
+
return body + [0] * ((PAD_BUCKET - len(body) % PAD_BUCKET) % PAD_BUCKET)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _unpad(p: List[int]) -> Optional[List[int]]:
|
|
45
|
+
n = p[0] * 256 + p[1]
|
|
46
|
+
return None if 2 + n > len(p) else p[2:2 + n]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ScratchClient:
|
|
50
|
+
def __init__(self, server_pub: List[int], entropy32: List[int]):
|
|
51
|
+
self.S = list(server_pub) # プロジェクトに埋め込んだサーバー公開鍵(公開情報)
|
|
52
|
+
self.e = list(entropy32) # 使い捨ての秘密鍵。送信しない・保存しない
|
|
53
|
+
self.E = sp.x25519_base(self.e) # 重い処理 #1
|
|
54
|
+
self.sid = self.E[:8]
|
|
55
|
+
self.dh_es = None
|
|
56
|
+
self.ready = False
|
|
57
|
+
self.tries = 0
|
|
58
|
+
self.send_ctr = 0
|
|
59
|
+
self.recv_last = 0
|
|
60
|
+
self.k_c2s = self.k_s2c = None
|
|
61
|
+
|
|
62
|
+
# ---- 定義 HELLOを作る
|
|
63
|
+
def hello(self) -> List[int]:
|
|
64
|
+
return self.sid + [T_HELLO] + self.E
|
|
65
|
+
|
|
66
|
+
# ---- 定義 返信を処理する
|
|
67
|
+
def on_reply(self, f: List[int]) -> bool:
|
|
68
|
+
if self.ready or self.tries >= MAX_REPLY_TRIES or len(f) != 73 or f[8] != T_REPLY or f[:8] != self.sid:
|
|
69
|
+
return False
|
|
70
|
+
self.tries += 1
|
|
71
|
+
R, sealed = f[9:41], f[41:]
|
|
72
|
+
if self.dh_es is None:
|
|
73
|
+
self.dh_es = sp.x25519(self.e, self.S) # 重い処理 #2(1回だけ。サーバー認証の要)
|
|
74
|
+
if _all_zero(self.dh_es):
|
|
75
|
+
return False
|
|
76
|
+
dh_ee = sp.x25519(self.e, R) # 重い処理 #3(偽返信 1 個につき 1 回)
|
|
77
|
+
if _all_zero(dh_ee): # R が位数の小さい点 -> 拒否
|
|
78
|
+
return False
|
|
79
|
+
inner = sp.hchacha20(self.dh_es, LABEL_MASTER)[:16]
|
|
80
|
+
master = sp.hchacha20(dh_ee, inner)
|
|
81
|
+
k_c2s = sp.hchacha20(master, LABEL_C2S)
|
|
82
|
+
k_s2c = sp.hchacha20(master, LABEL_S2C)
|
|
83
|
+
aad = self.sid + [T_REPLY] + self.E + R # E と R を認証 = 差し替え検出
|
|
84
|
+
pt = sp.aead_open(k_s2c, [0] * 12, aad, sealed)
|
|
85
|
+
if pt is None or pt != READY:
|
|
86
|
+
return False
|
|
87
|
+
self.k_c2s, self.k_s2c, self.ready = k_c2s, k_s2c, True
|
|
88
|
+
self.dh_es = dh_ee = master = None # 不要になった秘密を消す
|
|
89
|
+
return True
|
|
90
|
+
|
|
91
|
+
# ---- 定義 暗号化して送るフレームを作る
|
|
92
|
+
def seal(self, data: List[int]) -> List[int]:
|
|
93
|
+
assert self.ready
|
|
94
|
+
self.send_ctr = self.send_ctr + 1
|
|
95
|
+
hdr = self.sid + [T_C2S] + _ctr6(self.send_ctr)
|
|
96
|
+
nonce = [0, 0, 0, 0] + [self.send_ctr // 256 ** (7 - i) % 256 for i in range(8)]
|
|
97
|
+
return hdr + sp.aead_seal(self.k_c2s, nonce, hdr, _pad(data))
|
|
98
|
+
|
|
99
|
+
# ---- 定義 受信フレームを復号する(失敗なら None)
|
|
100
|
+
def open(self, f: List[int]) -> Optional[List[int]]:
|
|
101
|
+
if not self.ready or len(f) < 15 + PAD_BUCKET + 16 or f[8] != T_S2C or f[:8] != self.sid:
|
|
102
|
+
return None
|
|
103
|
+
ctr = 0
|
|
104
|
+
for i in range(6):
|
|
105
|
+
ctr = ctr * 256 + f[9 + i]
|
|
106
|
+
if ctr <= self.recv_last:
|
|
107
|
+
return None
|
|
108
|
+
nonce = [0, 0, 0, 0] + [ctr // 256 ** (7 - i) % 256 for i in range(8)]
|
|
109
|
+
pt = sp.aead_open(self.k_s2c, nonce, f[:15], f[15:])
|
|
110
|
+
if pt is None:
|
|
111
|
+
return None
|
|
112
|
+
self.recv_last = ctr
|
|
113
|
+
return _unpad(pt)
|