msgctl 0.1.0a1__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.
Files changed (176) hide show
  1. msg/__init__.py +2 -0
  2. msg/admin/__init__.py +0 -0
  3. msg/admin/backup_retirement.py +56 -0
  4. msg/admin/backups.py +341 -0
  5. msg/admin/custodial_check.py +77 -0
  6. msg/admin/diagnostics.py +750 -0
  7. msg/admin/market.py +91 -0
  8. msg/admin/market_check.py +341 -0
  9. msg/admin/money.py +322 -0
  10. msg/admin/preflight.py +142 -0
  11. msg/admin/recovery_replay.py +281 -0
  12. msg/admin/restore_database.py +37 -0
  13. msg/admin/root.py +347 -0
  14. msg/admin/rotation.py +197 -0
  15. msg/admin/token_delivery_check.py +54 -0
  16. msg/admin/upgrade_check.py +58 -0
  17. msg/application.py +163 -0
  18. msg/bootstrap.py +305 -0
  19. msg/cli.py +537 -0
  20. msg/client.py +725 -0
  21. msg/client_certificates.py +98 -0
  22. msg/client_content.py +43 -0
  23. msg/client_custodial.py +206 -0
  24. msg/client_market.py +104 -0
  25. msg/client_recovery.py +186 -0
  26. msg/client_secrets.py +58 -0
  27. msg/client_tokens.py +99 -0
  28. msg/client_upgrade.py +188 -0
  29. msg/config.py +318 -0
  30. msg/constants.py +11 -0
  31. msg/core/__init__.py +0 -0
  32. msg/core/batching.py +23 -0
  33. msg/core/codec.py +253 -0
  34. msg/core/contracts.py +128 -0
  35. msg/core/cursors.py +68 -0
  36. msg/core/email_address.py +33 -0
  37. msg/core/errors.py +22 -0
  38. msg/core/events.py +6 -0
  39. msg/core/execution_ports.py +20 -0
  40. msg/core/executor.py +193 -0
  41. msg/core/models.py +517 -0
  42. msg/core/packet.py +71 -0
  43. msg/core/permissions.py +14 -0
  44. msg/core/query.py +44 -0
  45. msg/core/read_query.py +60 -0
  46. msg/core/registry.py +173 -0
  47. msg/core/requests.py +58 -0
  48. msg/core/schema_policy.py +28 -0
  49. msg/core/schemas.py +30 -0
  50. msg/core/tags.py +21 -0
  51. msg/core/template_dsl.py +94 -0
  52. msg/core/text_patch.py +283 -0
  53. msg/core/tool_execution.py +28 -0
  54. msg/core/transfer.py +183 -0
  55. msg/daemon.py +255 -0
  56. msg/data/__init__.py +0 -0
  57. msg/data/bootstrap.json +72 -0
  58. msg/data/favicon.png +0 -0
  59. msg/data/logo-dark.svg +5 -0
  60. msg/data/logo.svg +5 -0
  61. msg/data/recovery-checkpoint.example.json +1 -0
  62. msg/data/recovery-checkpoint.schema.json +118 -0
  63. msg/data/shortcodes.json +1 -0
  64. msg/data/system/AGENTS.md +6 -0
  65. msg/data/system/rules/_index.md +17 -0
  66. msg/data/system/rules/auth.md +6 -0
  67. msg/data/system/rules/files.md +6 -0
  68. msg/data/system/rules/identity.md +6 -0
  69. msg/data/system/rules/protocol.md +6 -0
  70. msg/data/system/rules/read-write.md +6 -0
  71. msg/data/system/rules/recovery.md +6 -0
  72. msg/data/system/rules/security.md +10 -0
  73. msg/data/system/rules/topics.md +6 -0
  74. msg/extensions/__init__.py +1 -0
  75. msg/extensions/hosting.py +259 -0
  76. msg/extensions/keystore.py +96 -0
  77. msg/extensions/repositories.py +769 -0
  78. msg/extensions/rss.py +67 -0
  79. msg/extensions/ssh.py +264 -0
  80. msg/extensions/ssh_git.py +300 -0
  81. msg/extensions/tools.py +150 -0
  82. msg/hosting_runtime.py +138 -0
  83. msg/market/__init__.py +1 -0
  84. msg/market/arbitration.py +481 -0
  85. msg/market/delivery.py +338 -0
  86. msg/market/delivery_notifications.py +110 -0
  87. msg/market/delivery_targets.py +76 -0
  88. msg/market/email.py +100 -0
  89. msg/market/escrow.py +345 -0
  90. msg/market/orders.py +218 -0
  91. msg/market/policy.py +147 -0
  92. msg/market/rationale.py +91 -0
  93. msg/market/references.py +19 -0
  94. msg/market/targets.py +157 -0
  95. msg/plugins/__init__.py +28 -0
  96. msg/plugins/achievements.py +316 -0
  97. msg/plugins/batch.py +32 -0
  98. msg/plugins/bounty.py +366 -0
  99. msg/plugins/collaboration.py +261 -0
  100. msg/plugins/common.py +217 -0
  101. msg/plugins/communication.py +853 -0
  102. msg/plugins/content.py +855 -0
  103. msg/plugins/custodial_lifecycle.py +224 -0
  104. msg/plugins/delivery.py +230 -0
  105. msg/plugins/discovery.py +1266 -0
  106. msg/plugins/discussion.py +162 -0
  107. msg/plugins/extensions.py +10 -0
  108. msg/plugins/following.py +78 -0
  109. msg/plugins/hosting_capacity.py +52 -0
  110. msg/plugins/identity.py +1654 -0
  111. msg/plugins/money.py +221 -0
  112. msg/plugins/offers.py +183 -0
  113. msg/plugins/orders.py +273 -0
  114. msg/plugins/recovery.py +376 -0
  115. msg/plugins/schemas.py +5 -0
  116. msg/plugins/sharing.py +300 -0
  117. msg/plugins/store.py +249 -0
  118. msg/plugins/system.py +75 -0
  119. msg/plugins/transfer.py +249 -0
  120. msg/py.typed +0 -0
  121. msg/security/__init__.py +0 -0
  122. msg/security/age_keys.py +112 -0
  123. msg/security/authentication.py +141 -0
  124. msg/security/authorization.py +300 -0
  125. msg/security/backup_retirement.py +135 -0
  126. msg/security/capabilities.py +157 -0
  127. msg/security/certificates.py +183 -0
  128. msg/security/crypto.py +102 -0
  129. msg/security/custodial_migration.py +292 -0
  130. msg/security/custody_history.py +146 -0
  131. msg/security/network.py +81 -0
  132. msg/security/policy.py +67 -0
  133. msg/security/quarantine.py +19 -0
  134. msg/security/root_files.py +67 -0
  135. msg/security/rotation_journal.py +60 -0
  136. msg/security/sealed_box.py +70 -0
  137. msg/security/sharing_policy.py +29 -0
  138. msg/security/token_delivery.py +93 -0
  139. msg/security/vault.py +186 -0
  140. msg/storage/__init__.py +0 -0
  141. msg/storage/capacity.py +100 -0
  142. msg/storage/custodial_migration.py +22 -0
  143. msg/storage/git.py +435 -0
  144. msg/storage/ledger_migration.py +307 -0
  145. msg/storage/market_migration.py +49 -0
  146. msg/storage/postgres.py +663 -0
  147. msg/storage/query.py +39 -0
  148. msg/storage/read_only.py +126 -0
  149. msg/storage/session.py +403 -0
  150. msg/storage/sqlite.py +360 -0
  151. msg/storage/topic_event_migration.py +26 -0
  152. msg/storage/valkey_bus.py +45 -0
  153. msg/transports/__init__.py +0 -0
  154. msg/transports/client.py +183 -0
  155. msg/transports/dictionary.py +387 -0
  156. msg/transports/graphql.py +75 -0
  157. msg/transports/http.py +72 -0
  158. msg/transports/http_routes.py +1577 -0
  159. msg/transports/mcp.py +127 -0
  160. msg/transports/packet.py +42 -0
  161. msg/transports/read_tree_path.py +65 -0
  162. msg/transports/stdio.py +36 -0
  163. msg/transports/url_safety.py +123 -0
  164. msg/tui.py +368 -0
  165. msg/workers/__init__.py +1 -0
  166. msg/workers/effects.py +406 -0
  167. msg/workers/leases.py +19 -0
  168. msg/workers/mail.py +63 -0
  169. msg/workers/maintenance.py +310 -0
  170. msg/workers/sandbox.py +74 -0
  171. msg/workers/sandbox_child.py +189 -0
  172. msg/workers/webhook.py +160 -0
  173. msgctl-0.1.0a1.dist-info/METADATA +96 -0
  174. msgctl-0.1.0a1.dist-info/RECORD +176 -0
  175. msgctl-0.1.0a1.dist-info/WHEEL +4 -0
  176. msgctl-0.1.0a1.dist-info/entry_points.txt +4 -0
msg/security/vault.py ADDED
@@ -0,0 +1,186 @@
1
+ """Domain-separated AES-GCM storage for server-held custodial key material."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import hmac
6
+ import shutil
7
+ import subprocess
8
+ import tempfile
9
+ from pathlib import Path
10
+
11
+ from cryptography.exceptions import InvalidTag
12
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
13
+ from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey,X25519PublicKey
14
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
15
+ from cryptography.hazmat.primitives import hashes
16
+
17
+ from msg.core.codec import b64,canonical,unb64,wire
18
+ from msg.core.errors import Failure,require
19
+ from msg.security.age_keys import recipient_from_identity,private_from_identity
20
+ from msg.security.crypto import Ed25519Signer
21
+
22
+
23
+ def _aad(subject,key_id,kind):
24
+ return canonical({'domain':'msg-custodial-vault-v1','subject':subject,
25
+ 'key_id':key_id,'kind':kind})
26
+
27
+
28
+ def _seal(app,subject,key_id,kind,secret):
29
+ nonce=os.urandom(12)
30
+ return b64(nonce),b64(AESGCM(app._vault_key).encrypt(nonce,secret,
31
+ _aad(subject,key_id,kind)))
32
+
33
+
34
+ def store_keys(app,tx,subject,signer,age_identity,age_key_id,now):
35
+ require(recipient_from_identity(age_identity),'invalid_age_identity')
36
+ signing_nonce,signing_ciphertext=_seal(app,subject,signer.key_id,'identity',
37
+ signer.private_bytes())
38
+ age_nonce,age_ciphertext=_seal(app,subject,age_key_id,'encryption',
39
+ age_identity.encode('ascii'))
40
+ tx.execute('''INSERT INTO custodial_vault
41
+ (subject,signing_key_id,encryption_key_id,signing_nonce,signing_ciphertext,
42
+ age_nonce,age_ciphertext,status,created_at,destroyed_at)
43
+ VALUES (?,?,?,?,?,?,?,?,?,?)''',
44
+ (subject,signer.key_id,age_key_id,signing_nonce,signing_ciphertext,
45
+ age_nonce,age_ciphertext,'active',wire(now),None),write=True)
46
+
47
+
48
+ def _open(app,tx,subject,kind):
49
+ row=tx.one('''SELECT signing_key_id,encryption_key_id,signing_nonce,signing_ciphertext,
50
+ age_nonce,age_ciphertext,status FROM custodial_vault WHERE subject=?''',(subject,))
51
+ require(row is not None and (row[6]=='active' or
52
+ (kind=='encryption' and row[6]=='decrypt_only')),'custodial_vault_unavailable')
53
+ key_id,nonce,ciphertext=(row[0],row[2],row[3]) if kind=='identity' else (row[1],row[4],row[5])
54
+ require(nonce is not None and ciphertext is not None,'custodial_vault_unavailable')
55
+ try:
56
+ return key_id,AESGCM(app._vault_key).decrypt(unb64(nonce,limit=12),
57
+ unb64(ciphertext,limit=8192),_aad(subject,key_id,kind))
58
+ except (InvalidTag,ValueError,TypeError) as exc:
59
+ raise Failure('custodial_vault_corrupt') from exc
60
+
61
+
62
+ def open_signer(app,tx,subject):
63
+ key_id,secret=_open(app,tx,subject,'identity')
64
+ signer=Ed25519Signer.from_bytes(secret)
65
+ require(signer.key_id==key_id,'custodial_vault_corrupt')
66
+ return signer
67
+
68
+
69
+ def open_age_identity(app,tx,subject):
70
+ key_id,secret=_open(app,tx,subject,'encryption')
71
+ try:
72
+ identity=secret.decode('ascii')
73
+ except UnicodeError as exc:
74
+ raise Failure('custodial_vault_corrupt') from exc
75
+ from msg.security.age_keys import encryption_key_id,public_from_recipient
76
+ recipient=recipient_from_identity(identity)
77
+ require(encryption_key_id(public_from_recipient(recipient))==key_id,
78
+ 'custodial_vault_corrupt')
79
+ return identity
80
+
81
+
82
+ def rewrap_owned_age_ciphertext(identity,recipient,ciphertext):
83
+ """Bounded age-to-age conversion; never expose the decrypted bytes to a caller."""
84
+ from msg.security.age_keys import public_from_recipient
85
+ public_from_recipient(recipient)
86
+ require(type(ciphertext) is bytes and
87
+ ciphertext.startswith(b'age-encryption.org/v1\n') and
88
+ len(ciphertext)<=1048576,'custodial_rewrap_ciphertext_invalid')
89
+ executable=shutil.which('age')
90
+ require(executable is not None,'age_dependency_unavailable')
91
+ with tempfile.TemporaryDirectory(prefix='msg-custodial-rewrap-') as folder:
92
+ secret=Path(folder)/'identity'
93
+ secret.write_bytes((identity+'\n').encode('ascii'))
94
+ secret.chmod(0o600)
95
+ try:
96
+ opened=subprocess.run([executable,'--decrypt','--identity',str(secret)],
97
+ input=ciphertext,capture_output=True,timeout=15,check=False)
98
+ require(opened.returncode==0 and len(opened.stdout)<=1048576,
99
+ 'custodial_rewrap_decrypt_failed')
100
+ sealed=subprocess.run([executable,'--encrypt','--recipient',recipient],
101
+ input=opened.stdout,capture_output=True,timeout=15,check=False)
102
+ require(sealed.returncode==0 and
103
+ sealed.stdout.startswith(b'age-encryption.org/v1\n') and
104
+ len(sealed.stdout)<=1114112,'custodial_rewrap_encrypt_failed')
105
+ except (OSError,subprocess.TimeoutExpired) as exc:
106
+ raise Failure('custodial_rewrap_operation_failed') from exc
107
+ return sealed.stdout
108
+
109
+
110
+ def _upgrade_aad(subject,challenge_id):
111
+ return canonical({'domain':'msg-custodial-upgrade-ephemeral-v1',
112
+ 'subject':subject,'challenge_id':challenge_id})
113
+
114
+
115
+ def seal_upgrade_private(app,subject,challenge_id,private):
116
+ nonce=os.urandom(12)
117
+ cipher=AESGCM(app._vault_key).encrypt(nonce,private,
118
+ _upgrade_aad(subject,challenge_id))
119
+ return b64(nonce),b64(cipher)
120
+
121
+
122
+ def open_upgrade_private(app,subject,challenge_id,nonce,ciphertext):
123
+ try:
124
+ raw=AESGCM(app._vault_key).decrypt(unb64(nonce,limit=12),
125
+ unb64(ciphertext,limit=128),_upgrade_aad(subject,challenge_id))
126
+ return X25519PrivateKey.from_private_bytes(raw)
127
+ except (InvalidTag,ValueError,TypeError) as exc:
128
+ raise Failure('custodial_upgrade_challenge_corrupt') from exc
129
+
130
+
131
+ UPGRADE_CONTEXT_FIELDS=('subject_id','challenge_id','request_id','handle','public_key',
132
+ 'encryption_recipient','server_public','nonce','expires_at')
133
+
134
+
135
+ def _proof(shared,challenge):
136
+ context={name:challenge[name] for name in UPGRADE_CONTEXT_FIELDS}
137
+ key=HKDF(algorithm=hashes.SHA256(),length=32,
138
+ salt=unb64(context['nonce'],limit=64),
139
+ info=b'msg-custodial-upgrade-pop-v1').derive(shared)
140
+ return b64(hmac.digest(key,canonical(context),'sha256'))
141
+
142
+
143
+ def client_upgrade_proof(age_identity,challenge):
144
+ private=private_from_identity(age_identity)
145
+ shared=private.exchange(X25519PublicKey.from_public_bytes(
146
+ unb64(challenge['server_public'],limit=32)))
147
+ return _proof(shared,challenge)
148
+
149
+
150
+ def server_upgrade_proof(app,subject,challenge,nonce,ciphertext):
151
+ from msg.security.age_keys import public_from_recipient
152
+ private=open_upgrade_private(app,subject,challenge['challenge_id'],nonce,ciphertext)
153
+ shared=private.exchange(X25519PublicKey.from_public_bytes(
154
+ public_from_recipient(challenge['encryption_recipient'])))
155
+ return _proof(shared,challenge)
156
+
157
+
158
+ def seal_retired_encryption_key(app, tx, subject, old_key_id, new_recipient, challenge_id):
159
+ """Export only this subject's retired age subkey, encrypted to its proved new key."""
160
+ from msg.security.age_keys import encryption_key_id, public_from_recipient
161
+ row = tx.one('SELECT subject,recipient,retired_at,is_primary FROM encryption_subkeys WHERE key_id=?',
162
+ (old_key_id,))
163
+ require(row is not None and row[0] == subject and row[2] is not None and row[3] == 0,
164
+ 'custodial_recovery_requires_retired_owned_key')
165
+ vault = tx.one('SELECT encryption_key_id FROM custodial_vault WHERE subject=?', (subject,))
166
+ require(vault == (old_key_id,), 'custodial_recovery_key_mismatch')
167
+ primary = tx.one('SELECT key_id,recipient FROM encryption_subkeys WHERE subject=? AND is_primary=1',
168
+ (subject,))
169
+ new_key_id = encryption_key_id(public_from_recipient(new_recipient))
170
+ require(primary == (new_key_id, new_recipient) and new_key_id != old_key_id,
171
+ 'custodial_recovery_recipient_mismatch')
172
+ identity = open_age_identity(app, tx, subject)
173
+ payload = {'format': 'msg-custodial-retired-key-v1',
174
+ 'purpose': 'retired-encryption-subkey-recovery', 'subject_id': subject,
175
+ 'challenge_id': challenge_id, 'encryption_key_id': old_key_id,
176
+ 'encryption_recipient': row[1], 'age_identity': identity}
177
+ executable = shutil.which('age')
178
+ require(executable is not None, 'age_dependency_unavailable')
179
+ try:
180
+ result = subprocess.run([executable, '--encrypt', '--recipient', new_recipient],
181
+ input=canonical(payload), capture_output=True, timeout=15, check=False)
182
+ except (OSError, subprocess.TimeoutExpired):
183
+ raise Failure('custodial_recovery_encryption_failed') from None
184
+ require(result.returncode == 0 and result.stdout.startswith(b'age-encryption.org/v1\n'),
185
+ 'custodial_recovery_encryption_failed')
186
+ return result.stdout
File without changes
@@ -0,0 +1,100 @@
1
+ """Deployment-wide durability caps for tables that otherwise grow with fresh identities.
2
+
3
+ Personal entitlements stay elsewhere. These checks only stop one shared database
4
+ or content volume from being filled by unbounded metadata, staged bytes, or
5
+ ciphertext. They do not grant quota, delete user content, or clear quarantine.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from msg.core.codec import decode, loads
10
+ from msg.core.errors import Failure, require
11
+ from msg.core.models import Revision
12
+
13
+ MAX_TRANSFERS = 4096
14
+ MAX_STAGED_CHUNK_BYTES = 4 * 1024 * 1024 * 1024
15
+ MAX_PURGE_RECORDS = 4096
16
+ MAX_PENDING_CSRS = 1024
17
+ MAX_CSR_ROWS = 8192
18
+ MAX_QUEUED_WEBHOOKS = 1024
19
+ MAX_WEBHOOK_ROWS = 8192
20
+ MAX_RESULTS = 100_000
21
+ MAX_REACTIONS = 100_000
22
+ MAX_KEYSTORE_REVISIONS = 4096
23
+ MAX_KEYSTORE_BYTES = 64 * 1024 * 1024
24
+ _CAPACITY = 'storage_capacity_exceeded'
25
+
26
+
27
+ def _int(value):
28
+ if type(value) is bool:
29
+ raise Failure(_CAPACITY)
30
+ try:
31
+ number = int(value)
32
+ except (TypeError, ValueError, OverflowError) as exc:
33
+ raise Failure(_CAPACITY) from exc
34
+ require(number >= 0 and number == value, _CAPACITY)
35
+ return number
36
+
37
+
38
+ def _count(tx, sql, args=()):
39
+ return _int(tx.one(sql, args)[0])
40
+
41
+
42
+ def require_transfer_capacity(tx, extra_bytes=0, *, new_transfer=False):
43
+ require(type(extra_bytes) is int and extra_bytes >= 0, _CAPACITY)
44
+ if new_transfer:
45
+ require(_count(tx, 'SELECT COUNT(*) FROM transfers') < MAX_TRANSFERS, _CAPACITY)
46
+ staged = _count(tx, 'SELECT COALESCE(SUM(length), 0) FROM chunks')
47
+ require(staged + extra_bytes <= MAX_STAGED_CHUNK_BYTES, _CAPACITY)
48
+
49
+
50
+ def trim_purge_records(tx):
51
+ rows = tx.rows("SELECT key, value FROM settings WHERE key LIKE 'purge_record:%'")
52
+ if len(rows) <= MAX_PURGE_RECORDS:
53
+ return
54
+ def stamp(raw):
55
+ value = loads(raw)
56
+ require(isinstance(value, dict), _CAPACITY)
57
+ return str(value.get('time') or '')
58
+ ranked = sorted(rows, key=lambda row: (stamp(row[1]), row[0]))
59
+ for key, _value in ranked[:len(ranked) - MAX_PURGE_RECORDS]:
60
+ tx.execute('DELETE FROM settings WHERE key=?', (key,), write=True)
61
+
62
+
63
+ def require_csr_capacity(tx):
64
+ require(_count(tx, "SELECT COUNT(*) FROM csrs WHERE state='pending'") < MAX_PENDING_CSRS, _CAPACITY)
65
+ require(_count(tx, 'SELECT COUNT(*) FROM csrs') < MAX_CSR_ROWS, _CAPACITY)
66
+
67
+
68
+ def require_webhook_capacity(tx):
69
+ active = _count(tx, "SELECT COUNT(*) FROM jobs WHERE kind='webhook' AND state IN ('pending','running')")
70
+ require(active < MAX_QUEUED_WEBHOOKS, _CAPACITY)
71
+ total = _count(tx, "SELECT COUNT(*) FROM jobs WHERE kind='webhook'")
72
+ if total < MAX_WEBHOOK_ROWS:
73
+ return
74
+ overflow = total - MAX_WEBHOOK_ROWS + 1
75
+ finished = tx.rows(
76
+ "SELECT id FROM jobs WHERE kind='webhook' AND state IN ('done','failed') ORDER BY next_at, id LIMIT ?",
77
+ (overflow,))
78
+ for (job_id,) in finished:
79
+ tx.execute("DELETE FROM jobs WHERE id=? AND kind='webhook' AND state IN ('done','failed')",
80
+ (job_id,), write=True)
81
+ require(_count(tx, "SELECT COUNT(*) FROM jobs WHERE kind='webhook'") < MAX_WEBHOOK_ROWS, _CAPACITY)
82
+
83
+
84
+ def require_result_capacity(tx):
85
+ require(_count(tx, 'SELECT COUNT(*) FROM results') < MAX_RESULTS, _CAPACITY)
86
+
87
+
88
+ def require_reaction_capacity(tx):
89
+ require(_count(tx, 'SELECT COUNT(*) FROM reactions') < MAX_REACTIONS, _CAPACITY)
90
+
91
+
92
+ def require_keystore_capacity(tx, extra_bytes):
93
+ require(type(extra_bytes) is int and extra_bytes >= 0, _CAPACITY)
94
+ require(_count(tx, """SELECT COUNT(*) FROM revisions r JOIN resources s
95
+ ON s.id=r.resource_id WHERE s.type='keystore'""") < MAX_KEYSTORE_REVISIONS, _CAPACITY)
96
+ total = 0
97
+ for (raw,) in tx.execute("""SELECT r.body FROM revisions r JOIN resources s
98
+ ON s.id=r.resource_id WHERE s.type='keystore'"""):
99
+ total += _int(decode(Revision, loads(raw)).content.size)
100
+ require(total + extra_bytes <= MAX_KEYSTORE_BYTES, _CAPACITY)
@@ -0,0 +1,22 @@
1
+ """One idempotent PostgreSQL upgrade for the decrypt-only vault lifecycle."""
2
+ from msg.core.errors import require
3
+
4
+ LEGACY_STATUS = "CHECK ((status = ANY (ARRAY['active'::text, 'destroyed'::text])))"
5
+ LIFECYCLE_CHECK = """CHECK (
6
+ status IN ('active','decrypt_only','destroyed') AND
7
+ (status <> 'decrypt_only' OR (signing_nonce IS NULL AND signing_ciphertext IS NULL
8
+ AND age_nonce IS NOT NULL AND age_ciphertext IS NOT NULL)))"""
9
+
10
+
11
+ def migrate_custodial_vault(connection):
12
+ """Caller holds the schema advisory lock; neither keys nor rows are rewritten."""
13
+ old = connection.execute("""SELECT pg_get_constraintdef(oid) FROM pg_constraint
14
+ WHERE conrelid='custodial_vault'::regclass AND conname='custodial_vault_status_check'""").fetchone()
15
+ if old is not None:
16
+ require(old[0] == LEGACY_STATUS, 'custodial_vault_unknown_constraint')
17
+ connection.execute('ALTER TABLE custodial_vault DROP CONSTRAINT custodial_vault_status_check')
18
+ present = connection.execute("""SELECT 1 FROM pg_constraint WHERE conrelid='custodial_vault'::regclass
19
+ AND conname='custodial_vault_lifecycle_check'""").fetchone()
20
+ if present is None:
21
+ connection.execute('ALTER TABLE custodial_vault ADD CONSTRAINT custodial_vault_lifecycle_check '
22
+ + LIFECYCLE_CHECK)