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,98 @@
|
|
|
1
|
+
"""User CA keys stay on the client; the service verifies published certificates."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import replace
|
|
5
|
+
from datetime import timedelta
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import sys
|
|
8
|
+
from uuid import uuid4
|
|
9
|
+
|
|
10
|
+
from msg.core.codec import b64,canonical,decode,digest,wire,loads
|
|
11
|
+
from msg.core.errors import require
|
|
12
|
+
from msg.core.models import CertificateRequest,Certificate,Signature,CapabilityGrant,IssuancePolicy
|
|
13
|
+
from msg.security.certificates import csr_body,verify_csr,sign_certificate
|
|
14
|
+
from msg.security.crypto import Ed25519Signer,key_id
|
|
15
|
+
from msg.storage.git import durable_write
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def local_key(path, *, create=False):
|
|
19
|
+
path=Path(path)
|
|
20
|
+
if not path.exists() and create:
|
|
21
|
+
path.parent.mkdir(mode=0o700,parents=True,exist_ok=True)
|
|
22
|
+
key=Ed25519Signer.generate()
|
|
23
|
+
durable_write(path,key.private_bytes(),mode=0o600)
|
|
24
|
+
return key
|
|
25
|
+
require(path.is_file() and not path.is_symlink() and path.stat().st_mode&0o077==0,'unsafe_ca_key_permissions')
|
|
26
|
+
return Ed25519Signer.from_bytes(path.read_bytes())
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def request_certificate(client,spec,key_path=None):
|
|
30
|
+
require(client.state.subject and client.state.signer is not None,'registered_identity_required')
|
|
31
|
+
require(set(spec)<={'requested_issuer','kind','grants','issuance','requested_ttl_seconds','delegation_depth'},'unknown_csr_field')
|
|
32
|
+
issuer=spec.get('requested_issuer','u_root')
|
|
33
|
+
if issuer.startswith('/'):
|
|
34
|
+
result=client.checked(await client.call('discovery.get',{'id':issuer,'fields':['id']},anonymous=True))
|
|
35
|
+
issuer=result.data['id']
|
|
36
|
+
signer=local_key(key_path or client.state.directory/'ca.key',create=True) if spec.get('kind')=='ca' or key_path else client.state.signer
|
|
37
|
+
csr=CertificateRequest(resource_id='client-pending',applicant=client.state.subject,subject_id=client.state.subject,
|
|
38
|
+
requested_issuer=issuer,public_key=signer.public_key,kind=spec.get('kind','capability'),
|
|
39
|
+
grants=tuple(decode(CapabilityGrant,g) for g in spec.get('grants',())),
|
|
40
|
+
issuance=decode(IssuancePolicy,spec['issuance']) if spec.get('issuance') else None,
|
|
41
|
+
requested_ttl_seconds=spec.get('requested_ttl_seconds',86400),target_service=client.state.server,
|
|
42
|
+
delegation_depth=spec.get('delegation_depth',0),authority_sources=(),request_digest='',
|
|
43
|
+
possession_proof=Signature(key_id=signer.key_id,algorithm='ed25519',value=b''))
|
|
44
|
+
proof=signer.sign(canonical(csr_body(csr)),purpose='csr')
|
|
45
|
+
args={k:v for k,v in wire(csr).items() if k in {'requested_issuer','public_key','kind','grants','issuance',
|
|
46
|
+
'requested_ttl_seconds','delegation_depth'}}
|
|
47
|
+
args['possession_proof']=wire(proof)
|
|
48
|
+
journal=client.state.directory/('csr-'+digest(csr_body(csr))[7:39]+'.json')
|
|
49
|
+
pending=loads(journal.read_bytes()) if journal.exists() else {'request_id':uuid4().hex}
|
|
50
|
+
durable_write(journal,canonical(pending),mode=0o600)
|
|
51
|
+
result=await client.call('cert.request',args,request_id=pending['request_id'])
|
|
52
|
+
if result.status=='ok':
|
|
53
|
+
durable_write(journal,canonical({**pending,'csr_id':result.data['csr_id'],'request_digest':result.data['request_digest']}),mode=0o600)
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def issue_certificate(client,csr_id,issuer_certificate_id,key_path, *, expected_digest=None):
|
|
58
|
+
signer=local_key(key_path)
|
|
59
|
+
issuer_result=client.checked(await client.call('cert.get',{'id':issuer_certificate_id},anonymous=True))
|
|
60
|
+
parent=decode(Certificate,issuer_result.data['certificate'])
|
|
61
|
+
require(parent.kind=='ca' and parent.issuance is not None and parent.key_id==signer.key_id,'not_matching_ca_key')
|
|
62
|
+
require(parent.subject_id==client.state.subject and parent.subject_id!='u_root','local_only')
|
|
63
|
+
certificates=(issuer_certificate_id,)
|
|
64
|
+
result=client.checked(await client.call('cert.get',{'id':csr_id},signer=signer,certificates=certificates))
|
|
65
|
+
csr=decode(CertificateRequest,result.data['request'])
|
|
66
|
+
verify_csr(csr)
|
|
67
|
+
require(csr.requested_issuer==parent.subject_id,'wrong_requested_issuer')
|
|
68
|
+
# The review binds the exact immutable request, not a mutable title or path.
|
|
69
|
+
print(canonical({'request':wire(csr),'issuer':issuer_certificate_id}).decode(),file=sys.stderr)
|
|
70
|
+
if expected_digest is None:
|
|
71
|
+
require(sys.stdin.isatty(),'approval_digest_required')
|
|
72
|
+
expected_digest=input('Type the exact CSR digest to approve: ').strip()
|
|
73
|
+
require(expected_digest==csr.request_digest,'approval_digest_mismatch')
|
|
74
|
+
if result.data['state']['status']=='issued':
|
|
75
|
+
return await client.call('cert.get',{'id':result.data['state']['certificate_id']},anonymous=True)
|
|
76
|
+
require(result.data['state']['status']=='pending','csr_not_pending')
|
|
77
|
+
journal=client.state.directory/('issued-'+digest((csr_id,issuer_certificate_id))[7:39]+'.json')
|
|
78
|
+
if journal.exists():
|
|
79
|
+
pending=loads(journal.read_bytes())
|
|
80
|
+
require(pending['csr_digest']==csr.request_digest,'approval_digest_mismatch')
|
|
81
|
+
cert=decode(Certificate,pending['certificate'])
|
|
82
|
+
else:
|
|
83
|
+
now=client.clock()
|
|
84
|
+
require(parent.not_before<=now<parent.expires_at,'issuer_expired')
|
|
85
|
+
require(csr.requested_ttl_seconds<=parent.issuance.max_cert_ttl_seconds,'certificate_ttl_escalation')
|
|
86
|
+
cert=Certificate(resource_id='cert_'+uuid4().hex,serial='serial_'+uuid4().hex,subject_id=csr.subject_id,
|
|
87
|
+
key_id=key_id(csr.public_key),issuer_id=parent.subject_id,parent_certificate_id=parent.resource_id,
|
|
88
|
+
authority_sources=csr.authority_sources,kind=csr.kind,grants=csr.grants,not_before=now,
|
|
89
|
+
expires_at=min(now+timedelta(seconds=csr.requested_ttl_seconds),parent.expires_at),
|
|
90
|
+
target_service=csr.target_service,delegation_depth=csr.delegation_depth,issuance=csr.issuance,
|
|
91
|
+
signature=Signature(key_id=signer.key_id,algorithm='ed25519',value=b''))
|
|
92
|
+
cert=sign_certificate(cert,signer)
|
|
93
|
+
pending={'csr_digest':csr.request_digest,'certificate':wire(cert),'request_id':uuid4().hex}
|
|
94
|
+
durable_write(journal,canonical(pending),mode=0o600)
|
|
95
|
+
# Server revalidates issuance scope, operations, revocation, expiry, authority
|
|
96
|
+
# sources and remaining depth in the committing SQLite transaction.
|
|
97
|
+
return await client.call('cert.publish',{'csr_id':csr_id,'certificate':wire(cert)},signer=signer,
|
|
98
|
+
certificates=certificates,request_id=pending['request_id'])
|
msg/client_content.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Client-side immutable Revision signing for structured text patches.
|
|
2
|
+
|
|
3
|
+
The returned arguments go through the ordinary signed OperationRequest client.
|
|
4
|
+
A Revision signature is separate from both that request and the server receipt.
|
|
5
|
+
The caller must fetch and verify the exact current/base bytes beforehand.
|
|
6
|
+
"""
|
|
7
|
+
from uuid import uuid4
|
|
8
|
+
|
|
9
|
+
from msg.core.codec import canonical, digest, wire
|
|
10
|
+
from msg.core.errors import require
|
|
11
|
+
from msg.core.models import BlobRef, Revision
|
|
12
|
+
from msg.core.text_patch import apply_patch
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def signed_patch_arguments(signer, resource, previous, source, patch, *, subject,
|
|
16
|
+
created_at, actor=None, change_note=None, revision_id=None,
|
|
17
|
+
base_revision=None, base_generation=None, base_source=None):
|
|
18
|
+
require(previous.resource_id==resource.id and previous.id==resource.revision,
|
|
19
|
+
'revision_conflict')
|
|
20
|
+
require(digest(source.encode('utf-8'))==previous.content.digest and
|
|
21
|
+
len(source.encode('utf-8'))==previous.content.size,'content_digest_mismatch')
|
|
22
|
+
require(previous.content.media_type in {'text/plain','text/markdown'},'text_patch_required')
|
|
23
|
+
require(base_revision is None or base_source is not None,'base_content_required')
|
|
24
|
+
body=apply_patch(source,patch,base_source=base_source)
|
|
25
|
+
blob=BlobRef(digest=digest(body.encode('utf-8')),size=len(body.encode('utf-8')),
|
|
26
|
+
media_type=previous.content.media_type)
|
|
27
|
+
revision=Revision(format_version=1,id=revision_id or 'v_'+uuid4().hex,
|
|
28
|
+
resource_id=resource.id,parents=(previous.id,),content=blob,
|
|
29
|
+
relations=previous.relations,actor=actor or subject,subject=subject,
|
|
30
|
+
author=previous.author,created_at=created_at,manifest_digest='',
|
|
31
|
+
change_note=change_note,source_kind='user',source_version=1,source_digest=digest(patch))
|
|
32
|
+
manifest={key:value for key,value in wire(revision).items()
|
|
33
|
+
if key not in {'manifest_digest','signature'}}
|
|
34
|
+
signature=signer.sign(canonical(manifest),purpose='revision')
|
|
35
|
+
args={'id':resource.id,'base_revision':base_revision or previous.id,
|
|
36
|
+
'base_generation':resource.generation if base_generation is None else base_generation,
|
|
37
|
+
'patch':patch,'revision_id':revision.id,'content_created_at':wire(created_at),
|
|
38
|
+
'content_signature':wire(signature)}
|
|
39
|
+
if change_note is not None:
|
|
40
|
+
args['change_note']=change_note
|
|
41
|
+
if base_revision is not None:
|
|
42
|
+
args['rebase']=True
|
|
43
|
+
return args
|
msg/client_custodial.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Local, journaled custodial history recovery. Plaintext never leaves this client."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import tempfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
from msg.client_recovery import _age, _protected_file
|
|
9
|
+
from msg.client_secrets import write_private
|
|
10
|
+
from msg.core.codec import canonical, digest, loads, wire
|
|
11
|
+
from msg.core.errors import Failure, require
|
|
12
|
+
from msg.security.age_keys import encryption_key_id, public_from_recipient, recipient_from_identity
|
|
13
|
+
from msg.security.custodial_migration import ack_statement, stage_statement
|
|
14
|
+
from msg.security.vault import client_upgrade_proof
|
|
15
|
+
from msg.storage.git import durable_write
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def journal_for(state):
|
|
19
|
+
for name in ('custodial-upgrade.json', 'custodial-history.json'):
|
|
20
|
+
path = state.directory / name
|
|
21
|
+
if path.exists() or path.is_symlink():
|
|
22
|
+
path = _protected_file(path)
|
|
23
|
+
require(path.stat().st_size <= 4 * 1024 * 1024, 'custodial_journal_too_large')
|
|
24
|
+
body = loads(path.read_bytes())
|
|
25
|
+
challenge = body.get('challenge')
|
|
26
|
+
require(challenge and challenge['subject_id'] == state.subject and
|
|
27
|
+
challenge['public_key'] == body['public_key'] and
|
|
28
|
+
body['encryption_recipient'] == state.encryption_recipient and
|
|
29
|
+
body.get('server', state.server) == state.server,
|
|
30
|
+
'custodial_upgrade_journal_mismatch')
|
|
31
|
+
require(state.signer is not None and body['public_key'] == wire(state.signer.public_key),
|
|
32
|
+
'custodial_upgrade_journal_mismatch')
|
|
33
|
+
return path, body
|
|
34
|
+
raise Failure('custodial_upgrade_journal_missing')
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def inventory(client):
|
|
38
|
+
_, journal = journal_for(client.state)
|
|
39
|
+
return client.checked(await client.call('identity.custodial_upgrade_inventory',
|
|
40
|
+
{'challenge_id': journal['challenge']['challenge_id']}))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def transition(client, action, *, resolution='verified', loss_revisions=(), reason=None,
|
|
44
|
+
external_ciphertexts_migrated=False):
|
|
45
|
+
from msg.client_upgrade import locked_state
|
|
46
|
+
from msg.client_tokens import remove_journal
|
|
47
|
+
client._require_token_secret_transport()
|
|
48
|
+
with locked_state(client.state):
|
|
49
|
+
path, journal = journal_for(client.state)
|
|
50
|
+
challenge = journal['challenge']
|
|
51
|
+
pending = journal.get('pending_transition')
|
|
52
|
+
intent = {'action': action, 'resolution': resolution, 'loss_revisions': sorted(loss_revisions),
|
|
53
|
+
'reason': reason, 'external_ciphertexts_migrated': external_ciphertexts_migrated}
|
|
54
|
+
if pending is None:
|
|
55
|
+
state = (await inventory(client)).data
|
|
56
|
+
identity = _protected_file(client.state.age_key_path).read_text().strip()
|
|
57
|
+
require(recipient_from_identity(identity) == state['recipient'], 'encryption_identity_mismatch')
|
|
58
|
+
args = {'challenge_id': challenge['challenge_id'], 'action': action,
|
|
59
|
+
'age_proof': client_upgrade_proof(identity, challenge),
|
|
60
|
+
'external_ciphertexts_migrated': external_ciphertexts_migrated,
|
|
61
|
+
'inventory_digest': state['inventory_digest'], 'observed_digest': state['observed_digest'],
|
|
62
|
+
'results_digest': state['results_digest']}
|
|
63
|
+
if action == 'finalize':
|
|
64
|
+
args.update(resolution=resolution, loss_revisions=sorted(loss_revisions),
|
|
65
|
+
reviewed_policy_version=state['recovery_policy_version'])
|
|
66
|
+
if reason is not None:
|
|
67
|
+
args['reason'] = reason
|
|
68
|
+
request_id = uuid4().hex
|
|
69
|
+
args['migration_ack'] = wire(client.state.signer.sign(canonical(
|
|
70
|
+
stage_statement(client.state.subject, args, request_id)), purpose='custodial-decision-v1'))
|
|
71
|
+
pending = {'intent': intent, 'arguments': args, 'request_id': request_id}
|
|
72
|
+
journal['pending_transition'] = pending
|
|
73
|
+
journal['server'] = client.state.server
|
|
74
|
+
durable_write(path, canonical(journal), mode=0o600)
|
|
75
|
+
else:
|
|
76
|
+
require(pending['intent'] == intent, 'custodial_transition_pending')
|
|
77
|
+
async def recovered():
|
|
78
|
+
result = await client.call('identity.custodial_upgrade_result',
|
|
79
|
+
{'challenge_id': challenge['challenge_id']}, signer=client.state.signer,
|
|
80
|
+
subject=client.state.subject, certificates=())
|
|
81
|
+
require(result.status == 'ok' and result.data.get('finalization_request_id') ==
|
|
82
|
+
pending['request_id'], 'custodial_transition_recovery_pending')
|
|
83
|
+
return result
|
|
84
|
+
try:
|
|
85
|
+
result = await client.call('identity.custodial_upgrade_finish', pending['arguments'],
|
|
86
|
+
request_id=pending['request_id'], contract_version=2)
|
|
87
|
+
except Failure as exc:
|
|
88
|
+
if exc.code != 'transport_uncertain' or action != 'finalize':
|
|
89
|
+
raise
|
|
90
|
+
result = await recovered()
|
|
91
|
+
if result.status == 'error' and action == 'finalize' and result.error.code in {
|
|
92
|
+
'credential_revoked', 'credential_expired', 'custodial_token_required'}:
|
|
93
|
+
result = await recovered()
|
|
94
|
+
if result.status == 'ok':
|
|
95
|
+
journal.pop('pending_transition', None)
|
|
96
|
+
if action == 'finalize':
|
|
97
|
+
client.state.accept_identity(result)
|
|
98
|
+
journal['status'] = 'completed'
|
|
99
|
+
journal['completed_result'] = wire(result.data)
|
|
100
|
+
destination = client.state.directory / 'custodial-history.json'
|
|
101
|
+
durable_write(destination, canonical(journal), mode=0o600)
|
|
102
|
+
if path != destination:
|
|
103
|
+
remove_journal(path)
|
|
104
|
+
else:
|
|
105
|
+
durable_write(path, canonical(journal), mode=0o600)
|
|
106
|
+
elif result.error.code not in {'internal_error', 'transport_uncertain'}:
|
|
107
|
+
# A signed refusal proves this decision did not commit. A fresh
|
|
108
|
+
# observation may be taken; uncertain transport keeps the journal.
|
|
109
|
+
journal.pop('pending_transition', None)
|
|
110
|
+
durable_write(path, canonical(journal), mode=0o600)
|
|
111
|
+
return result
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def _ciphertext(client, ref, directory, name):
|
|
115
|
+
entry = client.checked(await client.call('keystore.get', {'id': ref['id'], 'revision': ref['revision']}))
|
|
116
|
+
require(entry.data['format'] == 'age' and entry.data['size'] <= 1114112,
|
|
117
|
+
'age_keystore_entry_required')
|
|
118
|
+
path = directory / name
|
|
119
|
+
await client.download(entry.output, path)
|
|
120
|
+
value = path.read_bytes()
|
|
121
|
+
require(digest(value) == ref['ciphertext_digest'], 'custodial_rewrap_ciphertext_mismatch')
|
|
122
|
+
return value
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def acknowledge(client, old_revision, *, method='rewrap'):
|
|
126
|
+
from msg.client_upgrade import locked_state
|
|
127
|
+
with locked_state(client.state):
|
|
128
|
+
state = (await inventory(client)).data
|
|
129
|
+
require(not state['inventory_changed'], 'custodial_inventory_changed')
|
|
130
|
+
source = next((item for item in state['sources'] if item['revision'] == old_revision), None)
|
|
131
|
+
require(source is not None, 'custodial_rewrap_not_in_frozen_inventory')
|
|
132
|
+
identity_path = _protected_file(client.state.age_key_path)
|
|
133
|
+
require(recipient_from_identity(identity_path.read_text().strip()) == state['recipient'],
|
|
134
|
+
'custodial_rewrap_recipient_mismatch')
|
|
135
|
+
with tempfile.TemporaryDirectory(prefix='custodial-history-', dir=client.state.directory) as temp:
|
|
136
|
+
directory = Path(temp)
|
|
137
|
+
if method == 'rewrap':
|
|
138
|
+
mapping = state['mappings'].get(old_revision)
|
|
139
|
+
require(mapping is not None and mapping['old'] == {k: v for k, v in source.items() if k != 'key_id'},
|
|
140
|
+
'custodial_rewrap_mapping_missing')
|
|
141
|
+
target = dict(mapping['new'], key_id=state['new_key_id'], recipient=state['recipient'])
|
|
142
|
+
ciphertext = await _ciphertext(client, mapping['new'], directory, 'mapped.age')
|
|
143
|
+
plaintext = _age('--decrypt', '--identity', str(identity_path), input_data=ciphertext)
|
|
144
|
+
target_ref = mapping['new']
|
|
145
|
+
else:
|
|
146
|
+
require(method == 'compatibility_recovery', 'invalid_recovery_method')
|
|
147
|
+
target = state['recovery_envelope']
|
|
148
|
+
require(target is not None and target['subject_id'] == client.state.subject and
|
|
149
|
+
target['old_key_id'] == source['key_id'] and target['new_key_id'] == state['new_key_id'] and
|
|
150
|
+
target['recipient'] == state['recipient'], 'custodial_recovery_envelope_mismatch')
|
|
151
|
+
encrypted_key = await _ciphertext(client, target['ciphertext'], directory, 'key.age')
|
|
152
|
+
raw = _age('--decrypt', '--identity', str(identity_path), input_data=encrypted_key)
|
|
153
|
+
payload = loads(raw)
|
|
154
|
+
require(set(payload) == {'format','purpose','subject_id','challenge_id','encryption_key_id',
|
|
155
|
+
'encryption_recipient','age_identity'} and
|
|
156
|
+
payload['format'] == 'msg-custodial-retired-key-v1' and
|
|
157
|
+
payload['purpose'] == 'retired-encryption-subkey-recovery' and
|
|
158
|
+
payload['subject_id'] == client.state.subject and
|
|
159
|
+
payload['challenge_id'] == state['challenge_id'] and
|
|
160
|
+
payload['encryption_key_id'] == source['key_id'], 'custodial_recovery_envelope_mismatch')
|
|
161
|
+
old_recipient = recipient_from_identity(payload['age_identity'])
|
|
162
|
+
require(old_recipient == payload['encryption_recipient'] and
|
|
163
|
+
encryption_key_id(public_from_recipient(old_recipient)) == source['key_id'],
|
|
164
|
+
'custodial_recovery_key_mismatch')
|
|
165
|
+
old_path = directory / 'retired.agekey'
|
|
166
|
+
write_private(old_path, (payload['age_identity'] + '\n').encode('ascii'))
|
|
167
|
+
ciphertext = await _ciphertext(client, source, directory, 'original.age')
|
|
168
|
+
plaintext = _age('--decrypt', '--identity', str(old_path), input_data=ciphertext)
|
|
169
|
+
target_ref = target['ciphertext']
|
|
170
|
+
request_id = uuid4().hex
|
|
171
|
+
plaintext_digest = digest(plaintext)
|
|
172
|
+
signed = ack_statement(client.state.subject, state['challenge_id'], state['inventory_digest'],
|
|
173
|
+
source, target, plaintext_digest, request_id, method=method)
|
|
174
|
+
signature = client.state.signer.sign(canonical(signed), purpose='custodial-history-ack-v1')
|
|
175
|
+
return client.checked(await client.call('identity.custodial_rewrap_ack', {
|
|
176
|
+
'challenge_id': state['challenge_id'], 'old_revision': old_revision,
|
|
177
|
+
'new_revision': target_ref['revision'], 'ciphertext_digest': target_ref['ciphertext_digest'],
|
|
178
|
+
'plaintext_digest': plaintext_digest, 'inventory_digest': state['inventory_digest'],
|
|
179
|
+
'method': method, 'decryption_ack': wire(signature)}, request_id=request_id, contract_version=2))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
async def migrate(client, *, method='rewrap', limit=100):
|
|
183
|
+
require(type(limit) is int and 1 <= limit <= 1000, 'invalid_migration_limit')
|
|
184
|
+
state = (await inventory(client)).data
|
|
185
|
+
require(not state['inventory_changed'], 'custodial_inventory_changed')
|
|
186
|
+
if method == 'compatibility_recovery' and state['recovery_envelope'] is None:
|
|
187
|
+
client.checked(await transition(client, 'recovery_envelope'))
|
|
188
|
+
outcomes = []
|
|
189
|
+
for source in state['sources']:
|
|
190
|
+
revision = source['revision']
|
|
191
|
+
if revision in state['verified_revisions']:
|
|
192
|
+
continue
|
|
193
|
+
if len(outcomes) == limit:
|
|
194
|
+
break
|
|
195
|
+
try:
|
|
196
|
+
chosen = 'rewrap' if source['key_id'] == state['new_key_id'] else method
|
|
197
|
+
if chosen == 'rewrap' and revision not in state['mappings']:
|
|
198
|
+
client.checked(await client.call('identity.custodial_rewrap_revision', {
|
|
199
|
+
'challenge_id': state['challenge_id'],
|
|
200
|
+
'ciphertext_ref': {'id': source['id'], 'revision': revision},
|
|
201
|
+
'old_encryption_key_id': state['old_key_id'], 'new_recipient': state['recipient']}))
|
|
202
|
+
result = await acknowledge(client, revision, method=chosen)
|
|
203
|
+
outcomes.append({'revision': revision, 'status': result.status, 'method': chosen})
|
|
204
|
+
except Failure as exc:
|
|
205
|
+
outcomes.append({'revision': revision, 'status': 'pending', 'error': exc.code})
|
|
206
|
+
return {'items': outcomes, 'inventory': wire((await inventory(client)).data), 'finalized': False}
|
msg/client_market.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Small CLI adapters over the declared market contracts, never a second API."""
|
|
2
|
+
from collections.abc import Mapping
|
|
3
|
+
from uuid import uuid4
|
|
4
|
+
|
|
5
|
+
from msg.core.codec import canonical, digest, parse_time, unb64, wire
|
|
6
|
+
from msg.core.errors import require
|
|
7
|
+
|
|
8
|
+
# Values are (operation, positional argument). JSON is parsed by the CLI's
|
|
9
|
+
# existing bounded JSON/@file/stdin reader. Exact prices remain explicit input.
|
|
10
|
+
COMMANDS = {
|
|
11
|
+
'money': {
|
|
12
|
+
'state':('money.state',None), 'banks':('money.banks',None),
|
|
13
|
+
'balance':('money.balance',None), 'ledger':('money.ledger','json'),
|
|
14
|
+
'offers':('money.offers',None), 'entitlements':('money.entitlements',None),
|
|
15
|
+
'transfer':('money.transfer','json'), 'redeem':('money.redeem','json'),
|
|
16
|
+
'purchase':('money.purchase_get','purchase_id'),
|
|
17
|
+
'settle':('money.purchase_settle','purchase_id'),
|
|
18
|
+
'cancel':('money.purchase_cancel','purchase_id'),
|
|
19
|
+
},
|
|
20
|
+
'store': {
|
|
21
|
+
'get':('store.listing_get','id'), 'create':('store.listing_create','json'),
|
|
22
|
+
'update':('store.listing_update','json'),
|
|
23
|
+
'deposit':('store.package_deposit','json'), 'package':('store.package_get','id'),
|
|
24
|
+
},
|
|
25
|
+
'bounty': {
|
|
26
|
+
'get':('bounty.get','listing_id'), 'create':('bounty.create','json'),
|
|
27
|
+
'top-up':('bounty.top_up','json'), 'pause':('bounty.pause','listing_id'),
|
|
28
|
+
'resume':('bounty.resume','listing_id'), 'close':('bounty.close','listing_id'),
|
|
29
|
+
'claims':('bounty.claims','listing_id'), 'challenge':('bounty.challenge','listing_id'),
|
|
30
|
+
'claim':('bounty.claim','json'), 'prove':(None,'listing_id'),
|
|
31
|
+
},
|
|
32
|
+
'orders': {
|
|
33
|
+
'create':('orders.create','json'), 'buy':('orders.buy','json'),
|
|
34
|
+
'pay':('orders.fund','json'), 'get':('orders.get','order_id'),
|
|
35
|
+
'list':('orders.list','json'), 'payment':('orders.payment','order_id'),
|
|
36
|
+
'cancel':('orders.cancel','order_id'), 'contract':('orders.contract','order_id'),
|
|
37
|
+
'resolve':('orders.dispute_execute','json'), 'dispute':('orders.dispute_open','json'),
|
|
38
|
+
'case':('orders.dispute_get','case_id'),
|
|
39
|
+
},
|
|
40
|
+
'delivery': {
|
|
41
|
+
'get':('delivery.get','order_id'), 'prepare':('delivery.prepare','order_id'),
|
|
42
|
+
'accept':('delivery.accept','json'), 'submit':('delivery.submit','json'),
|
|
43
|
+
'claim':('delivery.claim','json'),
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def add_commands(commands):
|
|
49
|
+
for group, actions in COMMANDS.items():
|
|
50
|
+
sub = commands.add_parser(group, help='Signed market operations; amounts are exact minor units.').add_subparsers(
|
|
51
|
+
dest='action', required=True)
|
|
52
|
+
for action, (operation, field) in actions.items():
|
|
53
|
+
help_text = ('Sign a current-key PoP challenge and claim; not a human/Sybil check.'
|
|
54
|
+
if group=='bounty' and action=='prove' else None)
|
|
55
|
+
cmd = sub.add_parser(action, help=help_text)
|
|
56
|
+
if field=='json':
|
|
57
|
+
cmd.add_argument('payload', nargs='?', default='{}', help='JSON, @file or - for stdin.')
|
|
58
|
+
elif field:
|
|
59
|
+
cmd.add_argument('value')
|
|
60
|
+
cmd.add_argument('--request-id', help='Reuse exactly this ID and input after an uncertain response.')
|
|
61
|
+
if operation is not None:
|
|
62
|
+
cmd.add_argument('--contract-version', type=int,
|
|
63
|
+
help='Explicit published operation version; never changes the signed input silently.')
|
|
64
|
+
if group=='store' and action=='update':
|
|
65
|
+
cmd.add_argument('--generation', type=int, required=True)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def prove_bounty(client, listing_id, request_id=None):
|
|
69
|
+
require(client.state.signer is not None and client.state.subject is not None, 'signing_identity_required')
|
|
70
|
+
request_id = request_id or uuid4().hex
|
|
71
|
+
# Repeat the SAME challenge request after a lost claim response. Never
|
|
72
|
+
# silently replace the nonce or extend TTL while retaining the claim ID.
|
|
73
|
+
challenge_id = 'bounty-challenge-' + digest((client.state.subject,listing_id,request_id))[7:39]
|
|
74
|
+
challenged = await client.call('bounty.challenge',{'listing_id':listing_id},request_id=challenge_id)
|
|
75
|
+
if challenged.status != 'ok':
|
|
76
|
+
return challenged
|
|
77
|
+
payload = challenged.data.get('challenge')
|
|
78
|
+
fields = {'challenge_id','listing_id','claimant_subject_id','nonce','issued_at','expires_at','verifier_version'}
|
|
79
|
+
require(isinstance(payload,Mapping) and set(payload)==fields and
|
|
80
|
+
payload['listing_id']==listing_id and payload['claimant_subject_id']==client.state.subject and
|
|
81
|
+
payload['verifier_version']==1 and len(unb64(payload['nonce'],limit=32))==24 and
|
|
82
|
+
0 < (parse_time(payload['expires_at'])-parse_time(payload['issued_at'])).total_seconds() <= 300,
|
|
83
|
+
'bounty_challenge_mismatch')
|
|
84
|
+
proof = client.state.signer.sign(canonical(payload),purpose='bounty-pop-v1')
|
|
85
|
+
return await client.call('bounty.claim',{'challenge_id':payload['challenge_id'],'proof':wire(proof)},
|
|
86
|
+
request_id=request_id)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def run_command(client, args, parse_arguments):
|
|
90
|
+
operation, field = COMMANDS[args.command][args.action]
|
|
91
|
+
if operation is None:
|
|
92
|
+
return await prove_bounty(client,args.value,args.request_id)
|
|
93
|
+
params = parse_arguments(args.payload) if field=='json' else {field:args.value} if field else {}
|
|
94
|
+
kwargs = {'request_id':args.request_id}
|
|
95
|
+
version = getattr(args, 'contract_version', None)
|
|
96
|
+
if version is not None:
|
|
97
|
+
require(type(version) is int and 1 <= version <= 2**31 - 1, 'invalid_contract_version')
|
|
98
|
+
kwargs['contract_version'] = version
|
|
99
|
+
elif operation=='money.redeem':
|
|
100
|
+
kwargs['contract_version']=2
|
|
101
|
+
if args.command=='store' and args.action=='update':
|
|
102
|
+
require(isinstance(params.get('id'),str),'listing_id_required')
|
|
103
|
+
kwargs['expected']=((params['id'],args.generation),)
|
|
104
|
+
return await client.call(operation,params,**kwargs)
|
msg/client_recovery.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Local age backup of a self-custody encryption subkey.
|
|
2
|
+
|
|
3
|
+
The service receives only ciphertext. Decrypting an envelope does not log in to
|
|
4
|
+
an account or grant the custodian any application permission.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from uuid import uuid4
|
|
14
|
+
|
|
15
|
+
from msg.client_secrets import write_private
|
|
16
|
+
from msg.core.codec import canonical, digest, loads, wire
|
|
17
|
+
from msg.core.errors import Failure, require
|
|
18
|
+
from msg.security.age_keys import (
|
|
19
|
+
encryption_key_id, public_from_recipient, recipient_from_identity,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _protected_file(path):
|
|
24
|
+
path=Path(path)
|
|
25
|
+
require(path.is_file() and not path.is_symlink() and
|
|
26
|
+
path.stat().st_uid==os.geteuid() and path.stat().st_mode&0o077==0,
|
|
27
|
+
'unsafe_encryption_key_permissions')
|
|
28
|
+
return path
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _age(*arguments,input_data):
|
|
32
|
+
executable=shutil.which('age')
|
|
33
|
+
require(executable is not None,'age_dependency_unavailable')
|
|
34
|
+
try:
|
|
35
|
+
result=subprocess.run([executable,*arguments],input=input_data,capture_output=True,
|
|
36
|
+
timeout=60,check=False)
|
|
37
|
+
except (OSError,subprocess.TimeoutExpired) as exc:
|
|
38
|
+
raise Failure('age_operation_failed') from exc
|
|
39
|
+
require(result.returncode==0,'age_operation_failed')
|
|
40
|
+
return result.stdout
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def create_recovery_envelope(state,recipients):
|
|
44
|
+
"""Return a standard age ciphertext for an independently recoverable key."""
|
|
45
|
+
require(state.subject is not None and state.encryption_recipient is not None,
|
|
46
|
+
'self_custody_identity_required')
|
|
47
|
+
recipients=tuple(recipients)
|
|
48
|
+
require(1<=len(recipients)<=8 and len(set(recipients))==len(recipients),
|
|
49
|
+
'invalid_recovery_recipients')
|
|
50
|
+
for recipient in recipients:
|
|
51
|
+
public_from_recipient(recipient)
|
|
52
|
+
require(any(recipient!=state.encryption_recipient for recipient in recipients),
|
|
53
|
+
'independent_recovery_recipient_required')
|
|
54
|
+
identity=_protected_file(state.age_key_path).read_text().strip()
|
|
55
|
+
require(recipient_from_identity(identity)==state.encryption_recipient,
|
|
56
|
+
'encryption_identity_mismatch')
|
|
57
|
+
key_id=encryption_key_id(public_from_recipient(state.encryption_recipient))
|
|
58
|
+
plaintext=canonical({'format':'msg-recovery-envelope-v1','purpose':'encryption-subkey-recovery',
|
|
59
|
+
'subject_id':state.subject,'encryption_key_id':key_id,
|
|
60
|
+
'encryption_recipient':state.encryption_recipient,'age_identity':identity})
|
|
61
|
+
arguments=['--encrypt']
|
|
62
|
+
for recipient in recipients:
|
|
63
|
+
arguments.extend(('--recipient',recipient))
|
|
64
|
+
ciphertext=_age(*arguments,input_data=plaintext)
|
|
65
|
+
require(ciphertext.startswith(b'age-encryption.org/v1\n'),'invalid_recovery_envelope')
|
|
66
|
+
return ciphertext,{'subject_id':state.subject,'encryption_key_id':key_id,
|
|
67
|
+
'recipient_fingerprints':tuple(encryption_key_id(public_from_recipient(r))
|
|
68
|
+
for r in recipients),'purpose':'encryption-subkey-recovery'}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def save_recovery_envelope(client,recipients, *, policy_version,name=None):
|
|
72
|
+
"""Store ciphertext and register its owner-declared metadata under an opted-in policy."""
|
|
73
|
+
ciphertext,metadata=create_recovery_envelope(client.state,recipients)
|
|
74
|
+
policy=client.checked(await client.call('identity.recovery_policy_get',{})).data
|
|
75
|
+
require(policy['opted_in'] and policy['version']==policy_version and
|
|
76
|
+
policy['encryption_key_id']==metadata['encryption_key_id'],
|
|
77
|
+
'recovery_policy_not_opted_in')
|
|
78
|
+
by_fingerprint={item['fingerprint']:item for item in policy['recipients']}
|
|
79
|
+
offered=set(metadata['recipient_fingerprints'])
|
|
80
|
+
require(offered<=by_fingerprint.keys(),'recovery_recipient_not_in_policy')
|
|
81
|
+
custodian_refs=sorted({by_fingerprint[fingerprint]['custodian_ref']
|
|
82
|
+
for fingerprint in offered
|
|
83
|
+
if by_fingerprint[fingerprint].get('custodian_ref') is not None})
|
|
84
|
+
name=name or 'recovery-'+metadata['encryption_key_id'][:18]
|
|
85
|
+
with tempfile.TemporaryDirectory(prefix='recovery-',dir=client.state.directory) as folder:
|
|
86
|
+
path=Path(folder)/'envelope.age'
|
|
87
|
+
write_private(path,ciphertext)
|
|
88
|
+
uploaded=client.checked(await client.upload(path,media_type='application/octet-stream'))
|
|
89
|
+
stored=client.checked(await client.call('keystore.put',{'name':name,'format':'age',
|
|
90
|
+
'source':wire(uploaded.output)}))
|
|
91
|
+
registered=client.checked(await client.call('identity.recovery_envelope_register',{
|
|
92
|
+
'ciphertext_ref':wire(stored.resources[0]),
|
|
93
|
+
'encryption_key_id':metadata['encryption_key_id'],'policy_version':policy_version,
|
|
94
|
+
'recipient_fingerprints':sorted(offered),'custodian_refs':custodian_refs,
|
|
95
|
+
'purpose':'encryption-subkey-recovery'}))
|
|
96
|
+
return stored,registered,metadata
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def restore_recovery_envelope(ciphertext,custodian_identity_path,output_path, *,
|
|
100
|
+
expected_subject_id,expected_encryption_key_id):
|
|
101
|
+
"""Offline decrypt and validate an envelope, writing the restored key 0600."""
|
|
102
|
+
require(type(ciphertext) is bytes and ciphertext.startswith(b'age-encryption.org/v1\n'),
|
|
103
|
+
'invalid_recovery_envelope')
|
|
104
|
+
custodian=_protected_file(custodian_identity_path)
|
|
105
|
+
plaintext=_age('--decrypt','--identity',str(custodian),input_data=ciphertext)
|
|
106
|
+
try:
|
|
107
|
+
payload=loads(plaintext)
|
|
108
|
+
require(payload['format']=='msg-recovery-envelope-v1' and
|
|
109
|
+
payload['purpose']=='encryption-subkey-recovery' and
|
|
110
|
+
payload['subject_id']==expected_subject_id and
|
|
111
|
+
payload['encryption_key_id']==expected_encryption_key_id,
|
|
112
|
+
'recovery_envelope_mismatch')
|
|
113
|
+
recipient=recipient_from_identity(payload['age_identity'])
|
|
114
|
+
require(recipient==payload['encryption_recipient'] and
|
|
115
|
+
encryption_key_id(public_from_recipient(recipient))==expected_encryption_key_id,
|
|
116
|
+
'recovery_envelope_mismatch')
|
|
117
|
+
except (KeyError,TypeError) as exc:
|
|
118
|
+
raise Failure('invalid_recovery_envelope') from exc
|
|
119
|
+
write_private(output_path,(payload['age_identity']+'\n').encode())
|
|
120
|
+
return {'subject_id':expected_subject_id,'encryption_key_id':expected_encryption_key_id,
|
|
121
|
+
'recipient':recipient,'restored_locally':True,'account_authority':'none'}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
async def rewrap_age_keystore_entry(client,resource_id,old_identity_path, *,
|
|
125
|
+
expected_revision):
|
|
126
|
+
"""Re-encrypt one selected age entry for the current subkey, never scanning a vault."""
|
|
127
|
+
require(client.state.subject is not None and client.state.signer is not None and
|
|
128
|
+
client.state.encryption_recipient is not None,'signing_identity_required')
|
|
129
|
+
old_identity=_protected_file(old_identity_path)
|
|
130
|
+
old_recipient=recipient_from_identity(old_identity.read_text().strip())
|
|
131
|
+
recipient=client.state.encryption_recipient
|
|
132
|
+
require(old_recipient!=recipient,'encryption_key_unchanged')
|
|
133
|
+
public_from_recipient(recipient)
|
|
134
|
+
entry=client.checked(await client.call('keystore.get',{
|
|
135
|
+
'id':resource_id,'revision':expected_revision}))
|
|
136
|
+
require(entry.data['format']=='age' and entry.data['size']<=1048576,
|
|
137
|
+
'age_keystore_entry_required')
|
|
138
|
+
current=client.checked(await client.call('discovery.get',{'id':resource_id,'view':'meta'}))
|
|
139
|
+
require(current.data['owner']==client.state.subject,'keystore_owner_required')
|
|
140
|
+
require(current.data['revision']==expected_revision,'revision_conflict')
|
|
141
|
+
with tempfile.TemporaryDirectory(prefix='rewrap-',dir=client.state.directory) as folder:
|
|
142
|
+
directory=Path(folder)
|
|
143
|
+
prior=directory/'prior.age'
|
|
144
|
+
await client.download(entry.output,prior)
|
|
145
|
+
plaintext=_age('--decrypt','--identity',str(old_identity),input_data=prior.read_bytes())
|
|
146
|
+
ciphertext=_age('--encrypt','--recipient',recipient,input_data=plaintext)
|
|
147
|
+
require(ciphertext.startswith(b'age-encryption.org/v1\n'),'invalid_ciphertext_envelope')
|
|
148
|
+
renewed=directory/'renewed.age'
|
|
149
|
+
write_private(renewed,ciphertext)
|
|
150
|
+
uploaded=client.checked(await client.upload(renewed,media_type='application/octet-stream'))
|
|
151
|
+
result=client.checked(await client.call('keystore.put',{
|
|
152
|
+
'id':resource_id,'name':current.data['name'],'format':'age',
|
|
153
|
+
'source':wire(uploaded.output)},
|
|
154
|
+
expected=((resource_id,current.data['generation']),)))
|
|
155
|
+
return result
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
async def verify_custodial_rewrap_entry(client,resource_id,revision,new_identity_path, *,
|
|
159
|
+
expected_recipient,expected_ciphertext_digest):
|
|
160
|
+
"""Owner-side age round trip; the plaintext stays in this process."""
|
|
161
|
+
identity=_protected_file(new_identity_path)
|
|
162
|
+
require(recipient_from_identity(identity.read_text().strip())==expected_recipient,
|
|
163
|
+
'custodial_rewrap_recipient_mismatch')
|
|
164
|
+
entry=client.checked(await client.call('keystore.get',{
|
|
165
|
+
'id':resource_id,'revision':revision}))
|
|
166
|
+
require(entry.data['format']=='age' and entry.data['size']<=1114112,
|
|
167
|
+
'custodial_rewrap_age_required')
|
|
168
|
+
with tempfile.TemporaryDirectory(prefix='verify-rewrap-',dir=client.state.directory) as folder:
|
|
169
|
+
path=Path(folder)/'ciphertext.age'
|
|
170
|
+
await client.download(entry.output,path)
|
|
171
|
+
ciphertext=path.read_bytes()
|
|
172
|
+
require(digest(ciphertext)==expected_ciphertext_digest,
|
|
173
|
+
'custodial_rewrap_ciphertext_mismatch')
|
|
174
|
+
plaintext=_age('--decrypt','--identity',str(identity),input_data=ciphertext)
|
|
175
|
+
return {'id':resource_id,'revision':revision,'recipient':expected_recipient,
|
|
176
|
+
'ciphertext_digest':expected_ciphertext_digest,'decrypted_locally':True,
|
|
177
|
+
'plaintext_digest':digest(plaintext),'upgrade_finalized':False}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
async def ack_custodial_rewrap_entry(client,challenge_id,old_revision,new_identity_path):
|
|
181
|
+
"""Compatibility wrapper for the scoped, locally decrypted ACK workflow."""
|
|
182
|
+
from msg.client_custodial import acknowledge, journal_for
|
|
183
|
+
_,journal=journal_for(client.state)
|
|
184
|
+
require(journal['challenge']['challenge_id']==challenge_id and
|
|
185
|
+
Path(new_identity_path)==client.state.age_key_path,'custodial_upgrade_journal_mismatch')
|
|
186
|
+
return await acknowledge(client,old_revision)
|