Coverage for node / src / stigmem_node / cli / federation.py: 88%

142 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-06-18 05:34 +0000

1"""Federation CLI handlers.""" 

2 

3from __future__ import annotations 

4 

5import argparse 

6import sys 

7from typing import Any 

8 

9 

10def _dnssec_pending_base_url(args: argparse.Namespace) -> str: 

11 """Resolve the local node base URL for the dnssec first-trust admin API.""" 

12 from ..settings import settings 

13 

14 return (args.node_url or settings.node_url).rstrip("/") 

15 

16 

17def _dnssec_auth_headers(args: argparse.Namespace) -> dict[str, str]: 

18 headers = {"Content-Type": "application/json"} 

19 if args.api_key: 19 ↛ 21line 19 didn't jump to line 21 because the condition on line 19 was always true

20 headers["Authorization"] = f"Bearer {args.api_key}" 

21 return headers 

22 

23 

24def _cmd_federation_dnssec_pending(args: argparse.Namespace) -> int: 

25 """List quarantined DNSSEC first-trust candidates (operator-confirm queue). 

26 

27 Calls ``GET /v1/federation/dnssec/pending`` on the local node (admin-gated). 

28 """ 

29 import json 

30 

31 import httpx 

32 

33 base = _dnssec_pending_base_url(args) 

34 try: 

35 resp = httpx.get( 

36 f"{base}/v1/federation/dnssec/pending", 

37 headers=_dnssec_auth_headers(args), 

38 timeout=15.0, 

39 ) 

40 except Exception as exc: 

41 print(f"error: cannot reach node at {base}: {exc}", file=sys.stderr) 

42 return 1 

43 

44 if resp.status_code != 200: 44 ↛ 45line 44 didn't jump to line 45 because the condition on line 44 was never true

45 print(f"error: node returned {resp.status_code}: {resp.text}", file=sys.stderr) 

46 return 1 

47 

48 pending: list[dict[str, Any]] = resp.json().get("pending", []) 

49 print(json.dumps({"pending": pending}, indent=2)) 

50 if not pending: 50 ↛ 51line 50 didn't jump to line 51 because the condition on line 50 was never true

51 print("no pending first-trust candidates", file=sys.stderr) 

52 return 0 

53 

54 

55def _cmd_federation_dnssec_confirm(args: argparse.Namespace) -> int: 

56 """Confirm a quarantined DNSSEC first-trust candidate (paste-to-confirm). 

57 

58 Calls ``POST /v1/federation/dnssec/pending/confirm`` on the local node. The 

59 operator-supplied ``--key-fpr`` MUST byte-equal the stored candidate 

60 fingerprint (NF-D4-5); a mismatch is rejected by the node (no trust) and this 

61 command exits non-zero. 

62 """ 

63 import json 

64 

65 import httpx 

66 

67 base = _dnssec_pending_base_url(args) 

68 payload = { 

69 "entity_uri": args.entity_uri, 

70 "node_id": args.node_id, 

71 "key_fpr": args.key_fpr, 

72 } 

73 try: 

74 resp = httpx.post( 

75 f"{base}/v1/federation/dnssec/pending/confirm", 

76 json=payload, 

77 headers=_dnssec_auth_headers(args), 

78 timeout=15.0, 

79 ) 

80 except Exception as exc: 

81 print(f"error: cannot reach node at {base}: {exc}", file=sys.stderr) 

82 return 1 

83 

84 if resp.status_code in (200, 201): 

85 print(json.dumps(resp.json(), indent=2)) 

86 print("first-trust candidate confirmed and pinned", file=sys.stderr) 

87 return 0 

88 if resp.status_code == 422: 

89 print( 

90 "error: fingerprint did not match the quarantined candidate — not trusted", 

91 file=sys.stderr, 

92 ) 

93 return 1 

94 if resp.status_code == 404: 94 ↛ 97line 94 didn't jump to line 97 because the condition on line 94 was always true

95 print("error: no such pending first-trust candidate", file=sys.stderr) 

96 return 1 

97 print(f"error: node returned {resp.status_code}: {resp.text}", file=sys.stderr) 

98 return 1 

99 

100 

101def _cmd_federation_dnssec_reject(args: argparse.Namespace) -> int: 

102 """Reject a quarantined DNSSEC first-trust candidate WITHOUT trusting it. 

103 

104 Calls ``POST /v1/federation/dnssec/pending/reject`` on the local node. 

105 """ 

106 import json 

107 

108 import httpx 

109 

110 base = _dnssec_pending_base_url(args) 

111 payload = {"entity_uri": args.entity_uri, "node_id": args.node_id} 

112 try: 

113 resp = httpx.post( 

114 f"{base}/v1/federation/dnssec/pending/reject", 

115 json=payload, 

116 headers=_dnssec_auth_headers(args), 

117 timeout=15.0, 

118 ) 

119 except Exception as exc: 

120 print(f"error: cannot reach node at {base}: {exc}", file=sys.stderr) 

121 return 1 

122 

123 if resp.status_code in (200, 204): 

124 if resp.status_code == 200 and resp.text: 124 ↛ 126line 124 didn't jump to line 126 because the condition on line 124 was always true

125 print(json.dumps(resp.json(), indent=2)) 

126 print("first-trust candidate rejected", file=sys.stderr) 

127 return 0 

128 if resp.status_code == 404: 128 ↛ 131line 128 didn't jump to line 131 because the condition on line 128 was always true

129 print("error: no such pending first-trust candidate", file=sys.stderr) 

130 return 1 

131 print(f"error: node returned {resp.status_code}: {resp.text}", file=sys.stderr) 

132 return 1 

133 

134 

135def _cmd_federation_register_peer(args: argparse.Namespace) -> int: 

136 """Register this node as a peer with a remote node (Spec-05-Federation-Trust).""" 

137 import base64 

138 import json 

139 import ssl 

140 from datetime import UTC, datetime 

141 

142 import httpx 

143 

144 from ..db import apply_migrations 

145 from ..settings import settings 

146 

147 # Ensure migrations are applied so keypair tables exist. 

148 apply_migrations() 

149 

150 # Resolve local node URL: explicit flag > settings. 

151 local_url = (args.local_url or settings.node_url).rstrip("/") 

152 remote_url = args.remote_url.rstrip("/") 

153 allowed_scopes: list[str] = [s.strip() for s in args.scopes.split(",") if s.strip()] 

154 cert = (args.tls_cert, args.tls_key) if args.tls_cert and args.tls_key else None 

155 verify: ssl.SSLContext | str | bool | None = None 

156 if cert is not None: 

157 ssl_ctx = ssl.create_default_context(cafile=args.ca_bundle or None) 

158 ssl_ctx.load_cert_chain(*cert) 

159 verify = ssl_ctx 

160 elif args.ca_bundle: 160 ↛ 161line 160 didn't jump to line 161 because the condition on line 160 was never true

161 verify = args.ca_bundle 

162 

163 # ------------------------------------------------------------------ 

164 # 1. Fetch local /.well-known/stigmem to get our published metadata. 

165 # ------------------------------------------------------------------ 

166 try: 

167 if verify is not None: 

168 with httpx.Client(timeout=15.0, trust_env=False, verify=verify) as client: 

169 wk = client.get(f"{local_url}/.well-known/stigmem") 

170 else: 

171 wk = httpx.get(f"{local_url}/.well-known/stigmem", timeout=10.0) 

172 wk.raise_for_status() 

173 except Exception as exc: 

174 print(f"error: cannot reach local node at {local_url}: {exc}", file=sys.stderr) 

175 return 1 

176 

177 wk_data = wk.json() 

178 local_node_id: str = wk_data["node_id"] 

179 local_pubkey: str = wk_data.get("federation_pubkey", "") 

180 if not local_pubkey: 

181 print( 

182 "error: local node has no federation_pubkey in /.well-known/stigmem — " 

183 "set STIGMEM_FEDERATION_ENABLED=true and restart", 

184 file=sys.stderr, 

185 ) 

186 return 1 

187 

188 # ------------------------------------------------------------------ 

189 # 2. Load local private key and sign the PeerDeclaration. 

190 # ------------------------------------------------------------------ 

191 from ..federation.peer_token import init_federation_keys 

192 

193 _, priv_b64 = init_federation_keys() 

194 

195 def _pad(s: str) -> str: 

196 return s + "=" * (-len(s) % 4) 

197 

198 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey 

199 

200 priv_key = Ed25519PrivateKey.from_private_bytes(base64.urlsafe_b64decode(_pad(priv_b64))) 

201 

202 signed_at = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ") 

203 signed_fields: dict[str, object] = { 

204 "allowed_scopes": sorted(allowed_scopes), 

205 "federation_pubkey": local_pubkey, 

206 "node_id": local_node_id, 

207 "node_url": local_url, 

208 "signed_at": signed_at, 

209 } 

210 canonical = json.dumps(signed_fields, sort_keys=True, separators=(",", ":")).encode("utf-8") 

211 sig_bytes = priv_key.sign(canonical) 

212 declaration_sig = base64.urlsafe_b64encode(sig_bytes).decode().rstrip("=") 

213 

214 # ------------------------------------------------------------------ 

215 # 3. POST to the remote node. 

216 # ------------------------------------------------------------------ 

217 payload = { 

218 "node_id": local_node_id, 

219 "node_url": local_url, 

220 "federation_pubkey": local_pubkey, 

221 "allowed_scopes": sorted(allowed_scopes), 

222 "signed_at": signed_at, 

223 "declaration_sig": declaration_sig, 

224 } 

225 

226 headers = {"Content-Type": "application/json"} 

227 if args.api_key: 

228 headers["Authorization"] = f"Bearer {args.api_key}" 

229 

230 try: 

231 if verify is not None: 

232 with httpx.Client(timeout=15.0, trust_env=False, verify=verify) as client: 

233 resp = client.post( 

234 f"{remote_url}/v1/federation/peers", 

235 json=payload, 

236 headers=headers, 

237 ) 

238 else: 

239 resp = httpx.post( 

240 f"{remote_url}/v1/federation/peers", 

241 json=payload, 

242 headers=headers, 

243 timeout=15.0, 

244 ) 

245 except Exception as exc: 

246 print(f"error: cannot reach remote node at {remote_url}: {exc}", file=sys.stderr) 

247 return 1 

248 

249 if resp.status_code in (200, 201): 

250 result = resp.json() 

251 peer_status = result.get("status", "unknown") 

252 peer_id = result.get("peer_id", "") 

253 if peer_status == "active": 

254 print(f"peer registered and verified (peer_id={peer_id})") 

255 else: 

256 print( 

257 f"peer registered but not yet active (status={peer_status}, peer_id={peer_id})\n" 

258 "Check that the remote node can reach this node's /.well-known/stigmem endpoint.", 

259 file=sys.stderr, 

260 ) 

261 return 1 

262 elif resp.status_code == 409: 

263 print("peer already registered — nothing to do") 

264 else: 

265 print( 

266 f"error: remote node returned {resp.status_code}: {resp.text}", 

267 file=sys.stderr, 

268 ) 

269 return 1 

270 

271 return 0