Coverage for node / src / stigmem_node / identity / key_rotation.py: 100%

67 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-25 01:49 +0000

1"""Key-rotation primitive — spec §22.2 (Phase 12). 

2 

3Public surface: 

4 KeyRotationLogEntry — TL entry shape for a key-rotation event (§22.2.3) 

5 RotationResult — output bundle from rotate_key() 

6 generate_key_id() — derive hex key_id from Ed25519PublicKey 

7 sign_key_rotation_entry() — sign KeyRotationLogEntry with retiring key 

8 rotate_key() — orchestrate a full rotation or dry-run preview 

9 

10Security design (§22.2): 

11 - Dual-trust window: retiring key stays in accept_set for at least 

12 dual_trust_days (minimum 90 = max token TTL per §19.3.2 / §22.2.2). 

13 - Rotation event signed by old key (proves continuity from prior identity). 

14 - Updated manifest signed by new key and submitted to TL. 

15 - KeyRotationLogEntry signed by old key and submitted to TL separately, 

16 providing non-repudiation and allowing third-party auditability. 

17 - dry_run=True produces all artefacts for inspection without TL writes. 

18""" 

19 

20from __future__ import annotations 

21 

22import base64 

23import hashlib 

24from dataclasses import dataclass 

25from datetime import UTC, datetime, timedelta 

26 

27import canonicaljson 

28from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey 

29from cryptography.hazmat.primitives.serialization import ( 

30 Encoding, 

31 NoEncryption, 

32 PrivateFormat, 

33 PublicFormat, 

34) 

35 

36from .manifest import ( 

37 OrgManifest, 

38 RotationEvent, 

39 manifest_to_dict, 

40 sign_manifest, 

41 sign_rotation_event, 

42) 

43from .transparency_log import LogEntry, make_transparency_log 

44 

45_DUAL_TRUST_DAYS = 90 # §22.2.2: must be ≥ max token TTL 

46 

47 

48@dataclass 

49class KeyRotationLogEntry: 

50 """Transparency-log entry for a key-rotation event — spec §22.2.3. 

51 

52 `rotation_sig` is an Ed25519 signature by the retiring key over the 

53 JCS-canonical body of all fields except rotation_sig itself. This 

54 anchors the rotation event to the prior cryptographic identity and lets 

55 third parties verify the chain without trusting the submitter. 

56 """ 

57 

58 event_type: str # always "key_rotation" 

59 entity_uri: str 

60 old_key_id: str 

61 new_key_id: str 

62 rotated_at: str # ISO-8601 UTC 

63 dual_trust_expires_at: str # ISO-8601 UTC; old key tokens accepted until here 

64 manifest_log_index: int # TL index of the updated manifest; -1 on dry-run 

65 rotation_sig: str # base64url Ed25519 sig by retiring key 

66 

67 

68@dataclass 

69class RotationResult: 

70 """All artefacts produced by a successful key rotation.""" 

71 

72 new_private_key: Ed25519PrivateKey 

73 new_private_key_b64: str # base64url raw seed — store in secrets manager 

74 new_manifest: OrgManifest 

75 manifest_log_entry: LogEntry | None # None on dry-run 

76 rotation_log_entry: KeyRotationLogEntry 

77 rotation_tl_entry: LogEntry | None # None on dry-run 

78 

79 

80def generate_key_id(public_key: Ed25519PublicKey) -> str: 

81 """Derive a short deterministic hex key_id from raw Ed25519 public key bytes.""" 

82 raw = public_key.public_bytes(Encoding.Raw, PublicFormat.Raw) 

83 return hashlib.sha256(raw).hexdigest()[:16] 

84 

85 

86def sign_key_rotation_entry( 

87 entry: KeyRotationLogEntry, 

88 old_private_key: Ed25519PrivateKey, 

89) -> str: 

90 """Sign *entry* with the retiring private key. Returns base64url signature. 

91 

92 Signing body is JCS-canonical JSON of all fields except rotation_sig. 

93 Lexicographic key order is guaranteed by canonicaljson (RFC 8785). 

94 """ 

95 body = canonicaljson.encode_canonical_json( 

96 { 

97 "dual_trust_expires_at": entry.dual_trust_expires_at, 

98 "entity_uri": entry.entity_uri, 

99 "event_type": entry.event_type, 

100 "manifest_log_index": entry.manifest_log_index, 

101 "new_key_id": entry.new_key_id, 

102 "old_key_id": entry.old_key_id, 

103 "rotated_at": entry.rotated_at, 

104 } 

105 ) 

106 sig_bytes = old_private_key.sign(body) 

107 return base64.urlsafe_b64encode(sig_bytes).decode().rstrip("=") 

108 

109 

110def rotate_key( 

111 entity_uri: str, 

112 old_manifest: OrgManifest, 

113 old_private_key: Ed25519PrivateKey, 

114 *, 

115 dual_trust_days: int = _DUAL_TRUST_DAYS, 

116 manifest_validity_days: int = 365, 

117 dry_run: bool = False, 

118) -> RotationResult: 

119 """Orchestrate an Ed25519 key rotation (§22.2). 

120 

121 Steps performed: 

122 1. Generate new Ed25519 keypair; derive new_key_id. 

123 2. Sign the rotation event with the old (retiring) key. 

124 3. Build the updated manifest with the new key and rotation event appended. 

125 4. Re-sign the manifest with the new key. 

126 5. Submit updated manifest to the transparency log. 

127 6. Build and sign the KeyRotationLogEntry with the retiring key. 

128 7. Submit KeyRotationLogEntry to the transparency log. 

129 

130 Args: 

131 entity_uri: The rotating org's canonical URI. 

132 old_manifest: Current signed manifest; must already pass 

133 verify_manifest() — caller is responsible. 

134 old_private_key: Retiring Ed25519 private key. 

135 dual_trust_days: Days the retiring key stays in accept_set. 

136 Must be ≥ 90 (max token TTL per §19.3.2). 

137 manifest_validity_days: Validity window for the new manifest (≤ 365 

138 in strict trust_mode per §19.1.3). 

139 dry_run: Skip TL submission; manifest_log_index = -1. 

140 

141 Returns: 

142 RotationResult with new key material, updated manifest, and TL entries. 

143 

144 Raises: 

145 ValueError: if dual_trust_days < 90. 

146 TransparencyLogUnavailable: on TL write failure (non-dry-run only). 

147 """ 

148 if dual_trust_days < _DUAL_TRUST_DAYS: 

149 raise ValueError( 

150 f"dual_trust_days must be ≥ {_DUAL_TRUST_DAYS} (max token TTL §19.3.2); " 

151 f"got {dual_trust_days}" 

152 ) 

153 

154 now = datetime.now(UTC) 

155 rotated_at = now.isoformat().replace("+00:00", "Z") 

156 dual_trust_expires_at = ( 

157 (now + timedelta(days=dual_trust_days)).isoformat().replace("+00:00", "Z") 

158 ) 

159 

160 # 1. Generate new keypair 

161 new_priv = Ed25519PrivateKey.generate() 

162 new_pub = new_priv.public_key() 

163 new_priv_raw = new_priv.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption()) 

164 new_pub_raw = new_pub.public_bytes(Encoding.Raw, PublicFormat.Raw) 

165 new_priv_b64 = base64.urlsafe_b64encode(new_priv_raw).decode().rstrip("=") 

166 new_pub_b64 = base64.urlsafe_b64encode(new_pub_raw).decode().rstrip("=") 

167 new_key_id = generate_key_id(new_pub) 

168 

169 old_key_id = old_manifest.key_id 

170 old_pub_b64 = old_manifest.public_key 

171 

172 # 2. Sign rotation event with retiring key 

173 rot_sig = sign_rotation_event( 

174 previous_key_id=old_key_id, 

175 new_key_id=new_key_id, 

176 new_public_key_b64=new_pub_b64, 

177 rotated_at=rotated_at, 

178 private_key=old_private_key, 

179 ) 

180 rotation_event = RotationEvent( 

181 previous_key_id=old_key_id, 

182 new_key_id=new_key_id, 

183 new_public_key=new_pub_b64, 

184 rotated_at=rotated_at, 

185 signature=rot_sig, 

186 previous_public_key=old_pub_b64, # stored for §22.2 dual-trust verification 

187 ) 

188 

189 # 3–4. Build and sign new manifest 

190 new_issued_at = rotated_at 

191 new_expires_at = ( 

192 (now + timedelta(days=manifest_validity_days)).isoformat().replace("+00:00", "Z") 

193 ) 

194 new_manifest = OrgManifest( 

195 entity_uri=entity_uri, 

196 key_id=new_key_id, 

197 public_key=new_pub_b64, 

198 issued_at=new_issued_at, 

199 expires_at=new_expires_at, 

200 entities=list(old_manifest.entities), 

201 rotation_events=list(old_manifest.rotation_events) + [rotation_event], 

202 ) 

203 sign_manifest(new_manifest, new_priv) 

204 

205 if dry_run: 

206 rotation_log_entry = KeyRotationLogEntry( 

207 event_type="key_rotation", 

208 entity_uri=entity_uri, 

209 old_key_id=old_key_id, 

210 new_key_id=new_key_id, 

211 rotated_at=rotated_at, 

212 dual_trust_expires_at=dual_trust_expires_at, 

213 manifest_log_index=-1, 

214 rotation_sig="", 

215 ) 

216 return RotationResult( 

217 new_private_key=new_priv, 

218 new_private_key_b64=new_priv_b64, 

219 new_manifest=new_manifest, 

220 manifest_log_entry=None, 

221 rotation_log_entry=rotation_log_entry, 

222 rotation_tl_entry=None, 

223 ) 

224 

225 # 5. Submit updated manifest to TL 

226 tl = make_transparency_log() 

227 manifest_log_entry = tl.submit(manifest_to_dict(new_manifest)) 

228 

229 # 6. Build and sign KeyRotationLogEntry (signed by retiring key) 

230 rotation_log_entry = KeyRotationLogEntry( 

231 event_type="key_rotation", 

232 entity_uri=entity_uri, 

233 old_key_id=old_key_id, 

234 new_key_id=new_key_id, 

235 rotated_at=rotated_at, 

236 dual_trust_expires_at=dual_trust_expires_at, 

237 manifest_log_index=manifest_log_entry.log_index, 

238 rotation_sig="", 

239 ) 

240 rotation_log_entry.rotation_sig = sign_key_rotation_entry(rotation_log_entry, old_private_key) 

241 

242 # 7. Submit KeyRotationLogEntry to TL 

243 rotation_entry_dict = { 

244 "dual_trust_expires_at": rotation_log_entry.dual_trust_expires_at, 

245 "entity_uri": rotation_log_entry.entity_uri, 

246 "event_type": rotation_log_entry.event_type, 

247 "manifest_log_index": rotation_log_entry.manifest_log_index, 

248 "new_key_id": rotation_log_entry.new_key_id, 

249 "old_key_id": rotation_log_entry.old_key_id, 

250 "rotated_at": rotation_log_entry.rotated_at, 

251 "rotation_sig": rotation_log_entry.rotation_sig, 

252 } 

253 rotation_tl_entry = tl.submit(rotation_entry_dict) 

254 

255 return RotationResult( 

256 new_private_key=new_priv, 

257 new_private_key_b64=new_priv_b64, 

258 new_manifest=new_manifest, 

259 manifest_log_entry=manifest_log_entry, 

260 rotation_log_entry=rotation_log_entry, 

261 rotation_tl_entry=rotation_tl_entry, 

262 )