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.
- msg/__init__.py +2 -0
- msg/admin/__init__.py +0 -0
- msg/admin/backup_retirement.py +56 -0
- msg/admin/backups.py +341 -0
- msg/admin/custodial_check.py +77 -0
- msg/admin/diagnostics.py +750 -0
- msg/admin/market.py +91 -0
- msg/admin/market_check.py +341 -0
- msg/admin/money.py +322 -0
- msg/admin/preflight.py +142 -0
- msg/admin/recovery_replay.py +281 -0
- msg/admin/restore_database.py +37 -0
- msg/admin/root.py +347 -0
- msg/admin/rotation.py +197 -0
- msg/admin/token_delivery_check.py +54 -0
- msg/admin/upgrade_check.py +58 -0
- msg/application.py +163 -0
- msg/bootstrap.py +305 -0
- msg/cli.py +537 -0
- msg/client.py +725 -0
- msg/client_certificates.py +98 -0
- msg/client_content.py +43 -0
- msg/client_custodial.py +206 -0
- msg/client_market.py +104 -0
- msg/client_recovery.py +186 -0
- msg/client_secrets.py +58 -0
- msg/client_tokens.py +99 -0
- msg/client_upgrade.py +188 -0
- msg/config.py +318 -0
- msg/constants.py +11 -0
- msg/core/__init__.py +0 -0
- msg/core/batching.py +23 -0
- msg/core/codec.py +253 -0
- msg/core/contracts.py +128 -0
- msg/core/cursors.py +68 -0
- msg/core/email_address.py +33 -0
- msg/core/errors.py +22 -0
- msg/core/events.py +6 -0
- msg/core/execution_ports.py +20 -0
- msg/core/executor.py +193 -0
- msg/core/models.py +517 -0
- msg/core/packet.py +71 -0
- msg/core/permissions.py +14 -0
- msg/core/query.py +44 -0
- msg/core/read_query.py +60 -0
- msg/core/registry.py +173 -0
- msg/core/requests.py +58 -0
- msg/core/schema_policy.py +28 -0
- msg/core/schemas.py +30 -0
- msg/core/tags.py +21 -0
- msg/core/template_dsl.py +94 -0
- msg/core/text_patch.py +283 -0
- msg/core/tool_execution.py +28 -0
- msg/core/transfer.py +183 -0
- msg/daemon.py +255 -0
- msg/data/__init__.py +0 -0
- msg/data/bootstrap.json +72 -0
- msg/data/favicon.png +0 -0
- msg/data/logo-dark.svg +5 -0
- msg/data/logo.svg +5 -0
- msg/data/recovery-checkpoint.example.json +1 -0
- msg/data/recovery-checkpoint.schema.json +118 -0
- msg/data/shortcodes.json +1 -0
- msg/data/system/AGENTS.md +6 -0
- msg/data/system/rules/_index.md +17 -0
- msg/data/system/rules/auth.md +6 -0
- msg/data/system/rules/files.md +6 -0
- msg/data/system/rules/identity.md +6 -0
- msg/data/system/rules/protocol.md +6 -0
- msg/data/system/rules/read-write.md +6 -0
- msg/data/system/rules/recovery.md +6 -0
- msg/data/system/rules/security.md +10 -0
- msg/data/system/rules/topics.md +6 -0
- msg/extensions/__init__.py +1 -0
- msg/extensions/hosting.py +259 -0
- msg/extensions/keystore.py +96 -0
- msg/extensions/repositories.py +769 -0
- msg/extensions/rss.py +67 -0
- msg/extensions/ssh.py +264 -0
- msg/extensions/ssh_git.py +300 -0
- msg/extensions/tools.py +150 -0
- msg/hosting_runtime.py +138 -0
- msg/market/__init__.py +1 -0
- msg/market/arbitration.py +481 -0
- msg/market/delivery.py +338 -0
- msg/market/delivery_notifications.py +110 -0
- msg/market/delivery_targets.py +76 -0
- msg/market/email.py +100 -0
- msg/market/escrow.py +345 -0
- msg/market/orders.py +218 -0
- msg/market/policy.py +147 -0
- msg/market/rationale.py +91 -0
- msg/market/references.py +19 -0
- msg/market/targets.py +157 -0
- msg/plugins/__init__.py +28 -0
- msg/plugins/achievements.py +316 -0
- msg/plugins/batch.py +32 -0
- msg/plugins/bounty.py +366 -0
- msg/plugins/collaboration.py +261 -0
- msg/plugins/common.py +217 -0
- msg/plugins/communication.py +853 -0
- msg/plugins/content.py +855 -0
- msg/plugins/custodial_lifecycle.py +224 -0
- msg/plugins/delivery.py +230 -0
- msg/plugins/discovery.py +1266 -0
- msg/plugins/discussion.py +162 -0
- msg/plugins/extensions.py +10 -0
- msg/plugins/following.py +78 -0
- msg/plugins/hosting_capacity.py +52 -0
- msg/plugins/identity.py +1654 -0
- msg/plugins/money.py +221 -0
- msg/plugins/offers.py +183 -0
- msg/plugins/orders.py +273 -0
- msg/plugins/recovery.py +376 -0
- msg/plugins/schemas.py +5 -0
- msg/plugins/sharing.py +300 -0
- msg/plugins/store.py +249 -0
- msg/plugins/system.py +75 -0
- msg/plugins/transfer.py +249 -0
- msg/py.typed +0 -0
- msg/security/__init__.py +0 -0
- msg/security/age_keys.py +112 -0
- msg/security/authentication.py +141 -0
- msg/security/authorization.py +300 -0
- msg/security/backup_retirement.py +135 -0
- msg/security/capabilities.py +157 -0
- msg/security/certificates.py +183 -0
- msg/security/crypto.py +102 -0
- msg/security/custodial_migration.py +292 -0
- msg/security/custody_history.py +146 -0
- msg/security/network.py +81 -0
- msg/security/policy.py +67 -0
- msg/security/quarantine.py +19 -0
- msg/security/root_files.py +67 -0
- msg/security/rotation_journal.py +60 -0
- msg/security/sealed_box.py +70 -0
- msg/security/sharing_policy.py +29 -0
- msg/security/token_delivery.py +93 -0
- msg/security/vault.py +186 -0
- msg/storage/__init__.py +0 -0
- msg/storage/capacity.py +100 -0
- msg/storage/custodial_migration.py +22 -0
- msg/storage/git.py +435 -0
- msg/storage/ledger_migration.py +307 -0
- msg/storage/market_migration.py +49 -0
- msg/storage/postgres.py +663 -0
- msg/storage/query.py +39 -0
- msg/storage/read_only.py +126 -0
- msg/storage/session.py +403 -0
- msg/storage/sqlite.py +360 -0
- msg/storage/topic_event_migration.py +26 -0
- msg/storage/valkey_bus.py +45 -0
- msg/transports/__init__.py +0 -0
- msg/transports/client.py +183 -0
- msg/transports/dictionary.py +387 -0
- msg/transports/graphql.py +75 -0
- msg/transports/http.py +72 -0
- msg/transports/http_routes.py +1577 -0
- msg/transports/mcp.py +127 -0
- msg/transports/packet.py +42 -0
- msg/transports/read_tree_path.py +65 -0
- msg/transports/stdio.py +36 -0
- msg/transports/url_safety.py +123 -0
- msg/tui.py +368 -0
- msg/workers/__init__.py +1 -0
- msg/workers/effects.py +406 -0
- msg/workers/leases.py +19 -0
- msg/workers/mail.py +63 -0
- msg/workers/maintenance.py +310 -0
- msg/workers/sandbox.py +74 -0
- msg/workers/sandbox_child.py +189 -0
- msg/workers/webhook.py +160 -0
- msgctl-0.1.0a1.dist-info/METADATA +96 -0
- msgctl-0.1.0a1.dist-info/RECORD +176 -0
- msgctl-0.1.0a1.dist-info/WHEEL +4 -0
- 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
|
msg/storage/__init__.py
ADDED
|
File without changes
|
msg/storage/capacity.py
ADDED
|
@@ -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)
|