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
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Read-only evidence for a bounded custody migration, never retirement authority.
|
|
2
|
+
|
|
3
|
+
A valid ACK is the client's signed declaration that it decrypted one mapped copy.
|
|
4
|
+
It cannot prove that no external ciphertext, recipient or recoverable backup exists.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from msg.core.codec import canonical, decode, unb64
|
|
9
|
+
from msg.core.errors import Failure
|
|
10
|
+
from msg.core.models import Signature
|
|
11
|
+
from msg.security.age_keys import encryption_key_id, public_from_recipient
|
|
12
|
+
from msg.security.backup_retirement import verified as verified_backup
|
|
13
|
+
from msg.security.crypto import key_id, verify
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _sha256(value):
|
|
17
|
+
return (isinstance(value, str) and len(value) == 71 and
|
|
18
|
+
value.startswith('sha256:') and
|
|
19
|
+
all(char in '0123456789abcdef' for char in value[7:]))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _entry(value):
|
|
23
|
+
return (isinstance(value, dict) and set(value) == {'id', 'revision', 'ciphertext_digest'}
|
|
24
|
+
and all(isinstance(value[key], str) and value[key] for key in ('id', 'revision'))
|
|
25
|
+
and _sha256(value['ciphertext_digest']))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def history_evidence(subject_id, challenge_id, challenge, details, current):
|
|
29
|
+
"""Reverify persisted ACKs, not merely their presence or cached success flags.
|
|
30
|
+
|
|
31
|
+
Legacy mappings bind the keys through the immutable challenge. New mappings
|
|
32
|
+
additionally carry the exact old/new key IDs; a conflicting binding is denied.
|
|
33
|
+
Unknown or malformed records stay pending and are not repaired by this read.
|
|
34
|
+
"""
|
|
35
|
+
frozen = details.get('age_inventory')
|
|
36
|
+
mappings = details.get('rewrap_mappings', {})
|
|
37
|
+
acks = details.get('rewrap_acks', {})
|
|
38
|
+
result = {'verification_scope': 'enumerated_keystore_age_revisions',
|
|
39
|
+
'external_coverage': 'unknown', 'inventory_changed': True,
|
|
40
|
+
'mapped_count': len(mappings) if isinstance(mappings, dict) else 0,
|
|
41
|
+
'acked_count': len(acks) if isinstance(acks, dict) else 0,
|
|
42
|
+
'verified_acked_count': 0, 'known_ciphertexts_migrated': False,
|
|
43
|
+
'historical_revisions_require_migration': True}
|
|
44
|
+
try:
|
|
45
|
+
if not (isinstance(frozen, list) and all(_entry(item) for item in frozen)
|
|
46
|
+
and isinstance(mappings, dict) and isinstance(acks, dict)
|
|
47
|
+
and isinstance(current, list) and all(_entry(item) for item in current)):
|
|
48
|
+
return result
|
|
49
|
+
sources = {item['revision']: item for item in frozen}
|
|
50
|
+
if len(sources) != len(frozen):
|
|
51
|
+
return result
|
|
52
|
+
public = unb64(challenge['public_key'], limit=32)
|
|
53
|
+
if (challenge['subject_id'] != subject_id or challenge['challenge_id'] != challenge_id
|
|
54
|
+
or key_id(public) != details['new_identity_key_id']
|
|
55
|
+
or encryption_key_id(public_from_recipient(challenge['encryption_recipient']))
|
|
56
|
+
!= details['new_encryption_key_id']):
|
|
57
|
+
return result
|
|
58
|
+
copies = []
|
|
59
|
+
for revision, mapping in mappings.items():
|
|
60
|
+
if not (isinstance(mapping, dict) and mapping.get('old') == sources.get(revision)
|
|
61
|
+
and revision in sources and _entry(mapping.get('new'))
|
|
62
|
+
and mapping['new']['revision'] != revision
|
|
63
|
+
and mapping.get('recipient') == challenge['encryption_recipient']
|
|
64
|
+
and mapping.get('old_encryption_key_id', details['old_encryption_key_id'])
|
|
65
|
+
== details['old_encryption_key_id']
|
|
66
|
+
and mapping.get('new_encryption_key_id', details['new_encryption_key_id'])
|
|
67
|
+
== details['new_encryption_key_id']):
|
|
68
|
+
return result
|
|
69
|
+
copies.append(mapping['new'])
|
|
70
|
+
if len({item['revision'] for item in copies}) != len(copies):
|
|
71
|
+
return result
|
|
72
|
+
expected = sorted(frozen + copies, key=lambda item: (item['id'], item['revision']))
|
|
73
|
+
result['inventory_changed'] = current != expected
|
|
74
|
+
verified = 0
|
|
75
|
+
for revision, ack in acks.items():
|
|
76
|
+
mapping = mappings.get(revision)
|
|
77
|
+
if not (mapping is not None and isinstance(ack, dict)
|
|
78
|
+
and ack.get('old_revision') == revision
|
|
79
|
+
and ack.get('new_revision') == mapping['new']['revision']
|
|
80
|
+
and ack.get('ciphertext_digest') == mapping['new']['ciphertext_digest']
|
|
81
|
+
and _sha256(ack.get('plaintext_digest'))
|
|
82
|
+
and isinstance(ack.get('request_id'), str) and ack['request_id']):
|
|
83
|
+
continue
|
|
84
|
+
signed = {'subject_id': subject_id, 'challenge_id': challenge_id,
|
|
85
|
+
'old': mapping['old'], 'new': mapping['new'],
|
|
86
|
+
'plaintext_digest': ack['plaintext_digest'], 'request_id': ack['request_id']}
|
|
87
|
+
try:
|
|
88
|
+
verify(public, canonical(signed), decode(Signature, ack['signature']),
|
|
89
|
+
purpose='custodial-rewrap-ack')
|
|
90
|
+
except (Failure, KeyError, TypeError, ValueError):
|
|
91
|
+
continue
|
|
92
|
+
verified += 1
|
|
93
|
+
result['verified_acked_count'] = verified
|
|
94
|
+
complete = (set(mappings) == set(acks) == set(sources)
|
|
95
|
+
and verified == len(sources) and not result['inventory_changed'])
|
|
96
|
+
result['known_ciphertexts_migrated'] = complete
|
|
97
|
+
result['historical_revisions_require_migration'] = not complete
|
|
98
|
+
except (Failure, KeyError, TypeError, ValueError):
|
|
99
|
+
# Persisted damaged state is not successful evidence, nor a reason to
|
|
100
|
+
# expose ciphertext/secret details through an exception message.
|
|
101
|
+
pass
|
|
102
|
+
return result
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def retirement_evidence(tx, subject_id, details, *, history_recoverable, now=None):
|
|
106
|
+
"""Separate observable online retirement from locally attested backup retirement.
|
|
107
|
+
|
|
108
|
+
backup_retired needs one exact root-signed record imported at the physical
|
|
109
|
+
console; server_key_retired additionally needs observable online retirement.
|
|
110
|
+
"""
|
|
111
|
+
new_signing = tx.one('SELECT key_id FROM identity_keys WHERE subject=? AND is_primary=1',
|
|
112
|
+
(subject_id,))
|
|
113
|
+
new_encryption = tx.one('SELECT key_id FROM encryption_subkeys WHERE subject=? AND is_primary=1',
|
|
114
|
+
(subject_id,))
|
|
115
|
+
switched = (new_signing is not None and new_encryption is not None
|
|
116
|
+
and new_signing[0] == details.get('new_identity_key_id')
|
|
117
|
+
and new_encryption[0] == details.get('new_encryption_key_id'))
|
|
118
|
+
vault = tx.one('''SELECT status,signing_nonce,signing_ciphertext,age_nonce,age_ciphertext,
|
|
119
|
+
destroyed_at,signing_key_id,encryption_key_id FROM custodial_vault WHERE subject=?''',
|
|
120
|
+
(subject_id,))
|
|
121
|
+
destroyed = (vault is not None and vault[0] == 'destroyed' and vault[5] is not None
|
|
122
|
+
and all(value is None for value in vault[1:5])
|
|
123
|
+
and vault[6] == details.get('old_identity_key_id')
|
|
124
|
+
and vault[7] == details.get('old_encryption_key_id'))
|
|
125
|
+
old_signing = tx.one('SELECT retired_at,is_primary FROM identity_keys WHERE subject=? AND key_id=?',
|
|
126
|
+
(subject_id, details.get('old_identity_key_id')))
|
|
127
|
+
old_encryption = tx.one('SELECT retired_at,is_primary FROM encryption_subkeys WHERE subject=? AND key_id=?',
|
|
128
|
+
(subject_id, details.get('old_encryption_key_id')))
|
|
129
|
+
try:
|
|
130
|
+
credential = await tx.credential(details.get('old_identity_key_id'))
|
|
131
|
+
signing_revoked = credential.subject_id == subject_id and credential.revoked_at is not None
|
|
132
|
+
except Failure:
|
|
133
|
+
signing_revoked = False
|
|
134
|
+
online = (switched and destroyed and signing_revoked and
|
|
135
|
+
all(row is not None and row[0] is not None and not row[1]
|
|
136
|
+
for row in (old_signing, old_encryption)))
|
|
137
|
+
status, attestation = await verified_backup(tx, subject_id, details, now=now)
|
|
138
|
+
backup = attestation is not None and attestation.binds(subject_id, details)
|
|
139
|
+
server = online and backup
|
|
140
|
+
return {'identity_switched': switched, 'history_recoverable': history_recoverable,
|
|
141
|
+
'online_retired': online, 'backup_retired': backup, 'server_key_retired': server,
|
|
142
|
+
'backup_retirement_evidence': status if not backup else 'local_attestation_verified',
|
|
143
|
+
'backup_retirement_record': attestation.view() if backup else None,
|
|
144
|
+
'completion_status': ('server_key_retired' if server and history_recoverable
|
|
145
|
+
else 'pending_backup_retirement' if online and history_recoverable
|
|
146
|
+
else 'pending_history' if switched else 'pending_identity_switch')}
|
msg/security/network.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Network policies are intersected; parsing or DNS never grants authority."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import replace
|
|
5
|
+
import ipaddress
|
|
6
|
+
import re
|
|
7
|
+
from urllib.parse import urlsplit
|
|
8
|
+
|
|
9
|
+
from msg.core.errors import Failure,require
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def normalized_host(host):
|
|
13
|
+
require(isinstance(host,str) and host and len(host)<=253 and '%' not in host and
|
|
14
|
+
not any(ord(c)<=32 or ord(c)==127 for c in host),'invalid_host')
|
|
15
|
+
host=host.rstrip('.').lower()
|
|
16
|
+
try:
|
|
17
|
+
return str(ipaddress.ip_address(host))
|
|
18
|
+
except ValueError:
|
|
19
|
+
try:
|
|
20
|
+
value=host.encode('idna').decode('ascii')
|
|
21
|
+
except UnicodeError as exc:
|
|
22
|
+
raise Failure('invalid_host') from exc
|
|
23
|
+
require(all(re.fullmatch(r'[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?',part) for part in value.split('.')),'invalid_host')
|
|
24
|
+
return value
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def validate_url(url,method,policy):
|
|
28
|
+
require(type(url) is str and 0<len(url)<=8192 and '\\' not in url and
|
|
29
|
+
all(ord(c)>32 and ord(c)!=127 for c in url),'invalid_network_url')
|
|
30
|
+
try:
|
|
31
|
+
parsed=urlsplit(url)
|
|
32
|
+
require(parsed.scheme in policy.schemes and parsed.scheme in {'http','https'},'network_scheme_forbidden')
|
|
33
|
+
require(parsed.hostname is not None and parsed.username is None and parsed.password is None and not parsed.fragment,
|
|
34
|
+
'invalid_network_url')
|
|
35
|
+
host=normalized_host(parsed.hostname)
|
|
36
|
+
port=parsed.port or (443 if parsed.scheme=='https' else 80)
|
|
37
|
+
except ValueError as exc:
|
|
38
|
+
if isinstance(exc,Failure):raise
|
|
39
|
+
raise Failure('invalid_network_url') from exc
|
|
40
|
+
require(port in policy.ports,'network_port_forbidden')
|
|
41
|
+
require(method in policy.methods,'network_method_forbidden')
|
|
42
|
+
require(not policy.hosts or host in {normalized_host(h) for h in policy.hosts},'network_host_forbidden')
|
|
43
|
+
return parsed
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def validate_addresses(addresses,policy):
|
|
47
|
+
require(bool(addresses),'dns_no_address')
|
|
48
|
+
results=[]
|
|
49
|
+
for value in addresses:
|
|
50
|
+
require('%' not in value,'invalid_network_target')
|
|
51
|
+
try:
|
|
52
|
+
ip=ipaddress.ip_address(value)
|
|
53
|
+
except ValueError as exc:
|
|
54
|
+
raise Failure('invalid_network_target') from exc
|
|
55
|
+
actual=ip.ipv4_mapped if isinstance(ip,ipaddress.IPv6Address) and ip.ipv4_mapped else ip
|
|
56
|
+
if not policy.allow_private:
|
|
57
|
+
require(actual.is_global and not actual.is_multicast,'private_target_forbidden')
|
|
58
|
+
require(not actual.is_unspecified and not actual.is_multicast,'invalid_network_target')
|
|
59
|
+
results.append(value)
|
|
60
|
+
return results
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def intersect_policy(policy,constraints):
|
|
64
|
+
require(set(constraints)<={'hosts','schemes','ports','methods','timeout_ms','max_response_bytes','max_redirects'},
|
|
65
|
+
'unknown_network_constraint')
|
|
66
|
+
values={}
|
|
67
|
+
for field in ('hosts','schemes','ports','methods'):
|
|
68
|
+
if field not in constraints:continue
|
|
69
|
+
supplied=set(constraints[field])
|
|
70
|
+
if field=='hosts':
|
|
71
|
+
supplied={normalized_host(h) for h in supplied}
|
|
72
|
+
current=set(getattr(policy,field))
|
|
73
|
+
narrowed=supplied if field=='hosts' and not current else current&supplied
|
|
74
|
+
require(bool(narrowed),'network_policy_empty')
|
|
75
|
+
values[field]=tuple(sorted(narrowed)) if field=='hosts' else frozenset(narrowed)
|
|
76
|
+
for field in ('timeout_ms','max_response_bytes','max_redirects'):
|
|
77
|
+
if field in constraints:
|
|
78
|
+
value=constraints[field]
|
|
79
|
+
require(type(value) is int and value>=(0 if field=='max_redirects' else 1),'invalid_network_limit')
|
|
80
|
+
values[field]=min(value,getattr(policy,field))
|
|
81
|
+
return replace(policy,**values)
|
msg/security/policy.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Small pure mode/scope primitives. Certificate rights never replace entry checks."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from msg.core.errors import require
|
|
4
|
+
|
|
5
|
+
READ,WRITE,EXECUTE=4,2,1
|
|
6
|
+
CERTGATE,SETGID,STICKY=0o4000,0o2000,0o1000
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def selected_class(resource,subject,memberships):
|
|
10
|
+
if subject is not None and subject==resource.owner:
|
|
11
|
+
return 'owner'
|
|
12
|
+
if subject is not None and resource.group in memberships:
|
|
13
|
+
return 'group'
|
|
14
|
+
return 'other'
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def class_bits(mode,permission_class):
|
|
18
|
+
require(type(mode) is int and 0<=mode<=0o7777,'invalid_mode')
|
|
19
|
+
return (mode>>{'owner':6,'group':3,'other':0}[permission_class])&7
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def allows(resource,subject,memberships,permission):
|
|
23
|
+
mask={'read':READ,'write':WRITE,'execute':EXECUTE,'list':READ,'traverse':EXECUTE}[permission]
|
|
24
|
+
return class_bits(resource.mode,selected_class(resource,subject,memberships))&mask==mask
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def inherit_group(parent,primary_group):
|
|
28
|
+
return parent.group if parent.mode&SETGID else primary_group
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def format_mode(mode):
|
|
32
|
+
require(type(mode) is int and 0<=mode<=0o7777,'invalid_mode')
|
|
33
|
+
return f'{mode:04o}'
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def scope_contains(scope,resource_id,session):
|
|
37
|
+
if scope.resource_id==resource_id:
|
|
38
|
+
return True
|
|
39
|
+
return scope.descendants and any(r.id==scope.resource_id for r in await session.ancestors(resource_id))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def scope_subset(child,parent,session):
|
|
43
|
+
if child.resource_id==parent.resource_id:
|
|
44
|
+
return not child.descendants or parent.descendants
|
|
45
|
+
return parent.descendants and await scope_contains(parent,child.resource_id,session)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def grant_covers(grant,capability,operation,resource_id,session):
|
|
49
|
+
return (grant.capability==capability and grant.version==1 and operation in grant.operations
|
|
50
|
+
and await scope_contains(grant.scope,resource_id,session))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def constraints_subset(child,parent):
|
|
54
|
+
"""Lists narrow allowlists; upper bounds may only decrease. Unknown keys reject upstream."""
|
|
55
|
+
for name,value in parent.items():
|
|
56
|
+
if name not in child:
|
|
57
|
+
return False
|
|
58
|
+
proposed=child[name]
|
|
59
|
+
if name in {'hosts','methods','ports','schemes'}:
|
|
60
|
+
if not set(proposed)<=set(value):
|
|
61
|
+
return False
|
|
62
|
+
elif name in {'max_response_bytes','timeout_ms','max_redirects'}:
|
|
63
|
+
if type(proposed) is not int or proposed>value:
|
|
64
|
+
return False
|
|
65
|
+
elif proposed!=value:
|
|
66
|
+
return False
|
|
67
|
+
return True
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""A restored snapshot is not current authority until a local recovery is proven.
|
|
2
|
+
|
|
3
|
+
The gate is a database fact, not a deletable config marker. No network operation
|
|
4
|
+
may clear it. Absence alone means an ordinary installation; malformed, false or
|
|
5
|
+
unknown values fail closed. This is containment, not a revocation replay proof.
|
|
6
|
+
"""
|
|
7
|
+
from msg.core.errors import require
|
|
8
|
+
|
|
9
|
+
SETTING = 'recovery_quarantine'
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def active(session):
|
|
13
|
+
return session.one('SELECT value FROM settings WHERE key=?', (SETTING,)) is not None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def require_live_authority(session):
|
|
17
|
+
# Even anonymous access to formerly public content may revive an ACL that
|
|
18
|
+
# was revoked after the snapshot. Only the transport health probe remains.
|
|
19
|
+
require(not active(session), 'recovery_quarantined')
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Descriptor-checked private Root files and a nonblocking local rotation lock.
|
|
2
|
+
|
|
3
|
+
These helpers confer no Root authority. RootAdmin must still enforce the real
|
|
4
|
+
OS-console/PIN ceremony; isolated tests exercise only the internal file use case.
|
|
5
|
+
"""
|
|
6
|
+
import fcntl
|
|
7
|
+
import os
|
|
8
|
+
import stat
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from msg.core.errors import Failure, require
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _private_descriptor(fd):
|
|
16
|
+
info = os.fstat(fd)
|
|
17
|
+
require(stat.S_ISREG(info.st_mode) and info.st_uid == os.geteuid() and
|
|
18
|
+
stat.S_IMODE(info.st_mode) == 0o600 and info.st_nlink == 1,
|
|
19
|
+
'unsafe_root_private_file')
|
|
20
|
+
return info
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def read_private(path, *, limit=4 * 1024 * 1024):
|
|
24
|
+
try:
|
|
25
|
+
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC)
|
|
26
|
+
except OSError as exc:
|
|
27
|
+
raise Failure('unsafe_root_private_file') from exc
|
|
28
|
+
try:
|
|
29
|
+
before = _private_descriptor(fd)
|
|
30
|
+
require(0 < before.st_size <= limit, 'unsafe_root_private_file')
|
|
31
|
+
chunks, count = [], 0
|
|
32
|
+
while chunk := os.read(fd, min(65536, limit + 1 - count)):
|
|
33
|
+
count += len(chunk)
|
|
34
|
+
require(count <= limit, 'unsafe_root_private_file')
|
|
35
|
+
chunks.append(chunk)
|
|
36
|
+
after = os.fstat(fd)
|
|
37
|
+
require(before.st_size == count == after.st_size and
|
|
38
|
+
before.st_mtime_ns == after.st_mtime_ns and before.st_ctime_ns == after.st_ctime_ns,
|
|
39
|
+
'root_private_file_changed')
|
|
40
|
+
return b''.join(chunks)
|
|
41
|
+
finally:
|
|
42
|
+
os.close(fd)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@contextmanager
|
|
46
|
+
def rotation_lock(protected):
|
|
47
|
+
protected = Path(protected)
|
|
48
|
+
require(not protected.is_symlink(), 'unsafe_root_private_path')
|
|
49
|
+
protected.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
50
|
+
info = protected.stat()
|
|
51
|
+
require(stat.S_ISDIR(info.st_mode) and info.st_uid == os.geteuid() and
|
|
52
|
+
stat.S_IMODE(info.st_mode) == 0o700, 'unsafe_root_private_path')
|
|
53
|
+
try:
|
|
54
|
+
fd = os.open(protected / '.rotation.lock', os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW |
|
|
55
|
+
os.O_NONBLOCK | os.O_CLOEXEC, 0o600)
|
|
56
|
+
except OSError as exc:
|
|
57
|
+
raise Failure('unsafe_root_private_file') from exc
|
|
58
|
+
try:
|
|
59
|
+
_private_descriptor(fd)
|
|
60
|
+
try:
|
|
61
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
62
|
+
except BlockingIOError as exc:
|
|
63
|
+
raise Failure('root_rotation_busy', retryable=True) from exc
|
|
64
|
+
yield
|
|
65
|
+
finally:
|
|
66
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
67
|
+
os.close(fd)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Validate a locally approved root rotation without loading a network service."""
|
|
2
|
+
from msg.constants import ROOT_SUBJECT, ONLINE_CA
|
|
3
|
+
from msg.core.codec import canonical, decode, digest, unb64
|
|
4
|
+
from msg.core.errors import require
|
|
5
|
+
from msg.core.models import Certificate, CertificateRequest, Signature
|
|
6
|
+
from msg.security.certificates import certificate_body, verify_csr
|
|
7
|
+
from msg.security.crypto import Ed25519Signer, open_private_key, verify
|
|
8
|
+
|
|
9
|
+
_FIELDS = {'version', 'old_certificate', 'new_trust', 'new_envelope', 'online_csr',
|
|
10
|
+
'previous_envelope_digest', 'statement', 'new_signature', 'old_signature'}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def authorization(journal):
|
|
14
|
+
"""Public, signed commitments; never copy the encrypted root into an audit."""
|
|
15
|
+
return {'version': journal['version'], 'statement': journal['statement'],
|
|
16
|
+
'old_certificate_digest': digest(journal['old_certificate']),
|
|
17
|
+
'new_trust_digest': digest(journal['new_trust']),
|
|
18
|
+
'new_envelope_digest': digest(journal['new_envelope']),
|
|
19
|
+
'online_csr_digest': digest(journal['online_csr']),
|
|
20
|
+
'previous_envelope_digest': journal['previous_envelope_digest']}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def validate(journal, *, pin, service, online_public):
|
|
24
|
+
require(isinstance(journal, dict) and journal.get('version') == 2,
|
|
25
|
+
'rotation_journal_upgrade_required')
|
|
26
|
+
require(set(journal) == _FIELDS, 'invalid_rotation_journal')
|
|
27
|
+
signer = Ed25519Signer.from_bytes(open_private_key(journal['new_envelope'], pin))
|
|
28
|
+
trust = journal['new_trust']
|
|
29
|
+
require(set(trust) == {'version', 'public_key', 'certificate'} and trust['version'] == 1,
|
|
30
|
+
'invalid_trust_anchor')
|
|
31
|
+
require(signer.public_key == unb64(trust['public_key'], limit=32), 'root_key_mismatch')
|
|
32
|
+
proof = authorization(journal)
|
|
33
|
+
verify(signer.public_key, canonical(proof), decode(Signature, journal['new_signature']),
|
|
34
|
+
purpose='root-rotation')
|
|
35
|
+
old = decode(Certificate, journal['old_certificate'])
|
|
36
|
+
new = decode(Certificate, trust['certificate'])
|
|
37
|
+
statement = journal['statement']
|
|
38
|
+
require(statement['old_certificate'] == old.resource_id and
|
|
39
|
+
statement['new_certificate'] == new.resource_id and
|
|
40
|
+
statement['new_fingerprint'] == digest(signer.public_key) and
|
|
41
|
+
type(statement['previous_key_proved']) is bool and
|
|
42
|
+
statement['previous_key_proved'] == (journal['old_signature'] is not None),
|
|
43
|
+
'rotation_statement_mismatch')
|
|
44
|
+
require(old.resource_id != new.resource_id and old.key_id != new.key_id,
|
|
45
|
+
'rotation_keys_must_change')
|
|
46
|
+
require(new.key_id == signer.key_id and new.subject_id == new.issuer_id == ROOT_SUBJECT and
|
|
47
|
+
new.parent_certificate_id is None and new.kind == 'ca' and
|
|
48
|
+
new.issuance is not None and not new.authority_sources and
|
|
49
|
+
old.subject_id == ROOT_SUBJECT and
|
|
50
|
+
new.target_service == old.target_service == service, 'rotation_service_mismatch')
|
|
51
|
+
verify(signer.public_key, canonical(certificate_body(new)), new.signature, purpose='certificate')
|
|
52
|
+
csr = decode(CertificateRequest, journal['online_csr'])
|
|
53
|
+
verify_csr(csr)
|
|
54
|
+
require(csr.public_key == online_public and csr.subject_id == csr.applicant == ONLINE_CA and
|
|
55
|
+
csr.requested_issuer == ROOT_SUBJECT and csr.target_service == service and
|
|
56
|
+
csr.kind == 'ca' and csr.delegation_depth == 0 and not csr.authority_sources and
|
|
57
|
+
csr.issuance is not None and csr.issuance.max_child_ca_depth == 0 and
|
|
58
|
+
len(csr.grants) == 1 and csr.grants[0].capability == 'cert.issue',
|
|
59
|
+
'rotation_online_csr_mismatch')
|
|
60
|
+
return signer, old, new, csr, proof
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Client-side X25519 + HKDF-SHA256 + AES-256-GCM envelope.
|
|
2
|
+
|
|
3
|
+
This is a versioned msg envelope, not age, OpenPGP or a new cryptographic
|
|
4
|
+
primitive. Encryption keys are independent of signing keys. The server validates
|
|
5
|
+
only the envelope shape; it never receives a decryption key.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
import os
|
|
9
|
+
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
|
|
10
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
11
|
+
from cryptography.hazmat.primitives import hashes
|
|
12
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
13
|
+
from cryptography.exceptions import InvalidTag
|
|
14
|
+
from msg.core.codec import b64, unb64, canonical, loads
|
|
15
|
+
from msg.core.errors import Failure, require
|
|
16
|
+
|
|
17
|
+
FORMAT='msg-x25519-v1'
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def generate_key():
|
|
21
|
+
key=X25519PrivateKey.generate()
|
|
22
|
+
return key.private_bytes_raw(),key.public_key().public_bytes_raw()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def validate_envelope(content):
|
|
26
|
+
value=loads(content)
|
|
27
|
+
require(isinstance(value,dict) and set(value)=={'format','recipient','ephemeral','salt','nonce','ciphertext'},
|
|
28
|
+
'invalid_ciphertext_envelope')
|
|
29
|
+
require(value['format']==FORMAT,'invalid_ciphertext_format')
|
|
30
|
+
for name,size in (('recipient',32),('ephemeral',32),('salt',32),('nonce',12)):
|
|
31
|
+
require(len(unb64(value[name],limit=size))==size,'invalid_ciphertext_envelope')
|
|
32
|
+
require(len(unb64(value['ciphertext']))>=16,'invalid_ciphertext_envelope')
|
|
33
|
+
return value
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def derived(shared,salt,recipient,ephemeral):
|
|
37
|
+
return HKDF(algorithm=hashes.SHA256(),length=32,salt=salt,
|
|
38
|
+
info=b'msg-keystore-v1\0'+recipient+ephemeral).derive(shared)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def encrypt(plaintext,recipient_public):
|
|
42
|
+
require(isinstance(plaintext,bytes) and len(recipient_public)==32,'invalid_encryption_input')
|
|
43
|
+
ephemeral=X25519PrivateKey.generate()
|
|
44
|
+
public=ephemeral.public_key().public_bytes_raw()
|
|
45
|
+
salt,nonce=os.urandom(32),os.urandom(12)
|
|
46
|
+
header={'format':FORMAT,'recipient':b64(recipient_public),'ephemeral':b64(public),
|
|
47
|
+
'salt':b64(salt),'nonce':b64(nonce)}
|
|
48
|
+
try:
|
|
49
|
+
shared=ephemeral.exchange(X25519PublicKey.from_public_bytes(recipient_public))
|
|
50
|
+
except ValueError as exc:
|
|
51
|
+
raise Failure('invalid_encryption_recipient') from exc
|
|
52
|
+
key=derived(shared,salt,recipient_public,public)
|
|
53
|
+
ciphertext=AESGCM(key).encrypt(nonce,plaintext,canonical(header))
|
|
54
|
+
return canonical({**header,'ciphertext':b64(ciphertext)})
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def decrypt(content,private):
|
|
58
|
+
try:
|
|
59
|
+
value=validate_envelope(content)
|
|
60
|
+
key=X25519PrivateKey.from_private_bytes(private)
|
|
61
|
+
recipient=key.public_key().public_bytes_raw()
|
|
62
|
+
require(b64(recipient)==value['recipient'],'wrong_decryption_recipient')
|
|
63
|
+
ephemeral=unb64(value['ephemeral'])
|
|
64
|
+
shared=key.exchange(X25519PublicKey.from_public_bytes(ephemeral))
|
|
65
|
+
derived_key=derived(shared,unb64(value['salt']),recipient,ephemeral)
|
|
66
|
+
header={k:v for k,v in value.items() if k!='ciphertext'}
|
|
67
|
+
return AESGCM(derived_key).decrypt(unb64(value['nonce']),unb64(value['ciphertext']),canonical(header))
|
|
68
|
+
except (ValueError,InvalidTag,KeyError,TypeError) as exc:
|
|
69
|
+
if isinstance(exc,Failure):raise
|
|
70
|
+
raise Failure('ciphertext_authentication_failed') from exc
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Shared, current-state boundaries for grants, delegated sources and links.
|
|
2
|
+
|
|
3
|
+
Only the finite ShareGrant@2 contract (read, no constraints) is accepted. These
|
|
4
|
+
checks do not grant an owner, a role or a certificate any additional permission.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def share_target_error(registry, resource, chain, session):
|
|
10
|
+
"""Return the same denial at issuance and at every source revalidation."""
|
|
11
|
+
if resource.state != 'active' or any(item.state != 'active' for item in chain):
|
|
12
|
+
return 'share_forbidden_resource'
|
|
13
|
+
if registry.resource_type(resource.type, resource.type_version).container:
|
|
14
|
+
return 'share_container_forbidden'
|
|
15
|
+
if any(item.id in {'r_agents', 'r_rules', 't_last_will'} or item.type in {
|
|
16
|
+
'tool', 'csr', 'certificate', 'credential', 'legacy_directive',
|
|
17
|
+
'dm_conversation'} for item in chain):
|
|
18
|
+
return 'share_forbidden_resource'
|
|
19
|
+
if any(parent.type == 'user' and child.name in {'SOUL.md', 'AGENTS.md', 'todos'}
|
|
20
|
+
for parent, child in zip(chain, chain[1:])):
|
|
21
|
+
return 'share_forbidden_resource'
|
|
22
|
+
if any(session.setting('hosting_preview:' + item.id) or
|
|
23
|
+
session.setting('hosting_preview_file:' + item.id) for item in chain):
|
|
24
|
+
return 'share_forbidden_resource'
|
|
25
|
+
if session.one('SELECT 1 FROM dm_conversations WHERE resource_id IN (' +
|
|
26
|
+
','.join('?' for _ in chain) + ') LIMIT 1',
|
|
27
|
+
tuple(item.id for item in chain)) is not None:
|
|
28
|
+
return 'share_forbidden_resource'
|
|
29
|
+
return None
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""One-time credential delivery, distinct from service composition.
|
|
2
|
+
|
|
3
|
+
The handler records the verifier in its existing issuance transaction. After that
|
|
4
|
+
transaction commits, release claims the delivery in a separate write transaction;
|
|
5
|
+
only a committed claim can produce a response-only token. Lost responses must use
|
|
6
|
+
the pre-bound recovery operation, never replay a persistent secret. This module
|
|
7
|
+
owns no signer, vault, root capability, HTTP adapter or plugin registry.
|
|
8
|
+
|
|
9
|
+
Clock and window providers preserve the configured current values without giving
|
|
10
|
+
the domain access to the whole settings/Application object. These are trusted
|
|
11
|
+
in-process dependencies, not a Python sandbox or an additional authorization path.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from dataclasses import replace
|
|
17
|
+
from datetime import datetime, timedelta
|
|
18
|
+
import hashlib
|
|
19
|
+
import hmac
|
|
20
|
+
|
|
21
|
+
from msg.core.codec import b64, canonical, unb64, wire
|
|
22
|
+
from msg.core.contracts import MetadataStore
|
|
23
|
+
from msg.core.errors import require
|
|
24
|
+
from msg.core.requests import SECRET_DELIVERY_MIN_VERSION
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def recovery_verifier(value):
|
|
28
|
+
secret=unb64(value,limit=64)
|
|
29
|
+
require(len(secret)>=32,'invalid_recovery_secret')
|
|
30
|
+
return hashlib.sha256(b'token-recovery-v1\0'+secret).hexdigest()
|
|
31
|
+
|
|
32
|
+
class TokenDelivery:
|
|
33
|
+
def __init__(self, *, metadata: MetadataStore, token_secret: bytes,
|
|
34
|
+
clock: Callable[[], datetime], recovery_window: Callable[[], int]):
|
|
35
|
+
require(len(token_secret)==32,'invalid_service_key')
|
|
36
|
+
self.metadata=metadata
|
|
37
|
+
self._token_secret=token_secret
|
|
38
|
+
self.clock=clock
|
|
39
|
+
self.recovery_window=recovery_window
|
|
40
|
+
|
|
41
|
+
def issued_token(self,request,subject):
|
|
42
|
+
# Nonces are high-entropy claims, not random tokens persisted in results.
|
|
43
|
+
return hmac.digest(self._token_secret,b'issued-token-v1\0'+canonical({
|
|
44
|
+
'subject':subject,'request_id':request.request_id,'nonce':request.arguments['nonce']}),'sha256')
|
|
45
|
+
|
|
46
|
+
recovery_verifier=staticmethod(recovery_verifier)
|
|
47
|
+
|
|
48
|
+
def record_token_delivery(self,tx,request,credential,now,*,recovery_deadline=None):
|
|
49
|
+
"""Bind recovery before commit; neither token nor recovery secret is stored."""
|
|
50
|
+
require(request.operation in SECRET_DELIVERY_MIN_VERSION and
|
|
51
|
+
request.contract_version>=SECRET_DELIVERY_MIN_VERSION[request.operation],
|
|
52
|
+
'credential_delivery_upgrade_required')
|
|
53
|
+
field='new_recovery_secret' if request.operation=='identity.token_recover' else 'recovery_secret'
|
|
54
|
+
secret=unb64(request.arguments[field],limit=64)
|
|
55
|
+
require(secret!=unb64(request.arguments['nonce'],limit=64),'recovery_secret_not_independent')
|
|
56
|
+
if request.operation=='identity.token_recover':
|
|
57
|
+
require(secret!=unb64(request.arguments['recovery_secret'],limit=64),
|
|
58
|
+
'recovery_secret_not_independent')
|
|
59
|
+
expires=min(credential.expires_at,
|
|
60
|
+
recovery_deadline if recovery_deadline is not None else
|
|
61
|
+
now+timedelta(seconds=self.recovery_window()))
|
|
62
|
+
require(expires>now,'recovery_unavailable')
|
|
63
|
+
tx.execute('''INSERT INTO token_deliveries
|
|
64
|
+
(credential_id,subject,request_id,request_digest,recovery_verifier,recovery_expires_at,
|
|
65
|
+
claimed_at,consumed_at) VALUES (?,?,?,?,?,?,NULL,NULL)''',
|
|
66
|
+
(credential.id,credential.subject_id,request.request_id,request.payload_digest,
|
|
67
|
+
self.recovery_verifier(request.arguments[field]),wire(expires)),write=True)
|
|
68
|
+
|
|
69
|
+
async def release(self,request,result):
|
|
70
|
+
if result.status!='ok' or request.operation not in SECRET_DELIVERY_MIN_VERSION:
|
|
71
|
+
return result
|
|
72
|
+
require(request.contract_version>=SECRET_DELIVERY_MIN_VERSION[request.operation],
|
|
73
|
+
'credential_delivery_upgrade_required')
|
|
74
|
+
token=self.issued_token(request,result.subject)
|
|
75
|
+
async with self.metadata.transaction(write=True) as tx:
|
|
76
|
+
credential=await tx.credential(result.data['credential_id'])
|
|
77
|
+
require(credential.revoked_at is None and credential.expires_at>self.clock(),'credential_expired')
|
|
78
|
+
require(hmac.compare_digest(credential.verifier,hashlib.sha256(token).digest()),'invalid_token_result')
|
|
79
|
+
row=tx.one('''SELECT subject,request_id,request_digest,claimed_at,consumed_at
|
|
80
|
+
FROM token_deliveries WHERE credential_id=?''',(credential.id,))
|
|
81
|
+
require(row is not None and row[0]==result.subject and row[1]==request.request_id
|
|
82
|
+
and row[2]==request.payload_digest and row[4] is None,'token_delivery_unavailable',
|
|
83
|
+
details={'committed_result':wire(result,compact=True)})
|
|
84
|
+
require(row[3] is None,'token_delivery_unavailable',
|
|
85
|
+
details={'committed_result':wire(result,compact=True)})
|
|
86
|
+
updated=tx.execute('''UPDATE token_deliveries SET claimed_at=? WHERE credential_id=?
|
|
87
|
+
AND claimed_at IS NULL AND consumed_at IS NULL''',
|
|
88
|
+
(wire(self.clock()),credential.id),write=True)
|
|
89
|
+
require(updated.rowcount==1,'token_delivery_unavailable',
|
|
90
|
+
details={'committed_result':wire(result,compact=True)})
|
|
91
|
+
# The claim commits before the adapter sees the token. A dropped response
|
|
92
|
+
# must use a separate, pre-bound recovery secret to rotate the credential.
|
|
93
|
+
return replace(result,data=dict(result.data,token=b64(token)))
|