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/plugins/identity.py
ADDED
|
@@ -0,0 +1,1654 @@
|
|
|
1
|
+
"""Controlled identity, organization and certificate facts."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import replace
|
|
8
|
+
from datetime import timedelta
|
|
9
|
+
from msg.constants import *
|
|
10
|
+
from msg.core.codec import b64,unb64,canonical,decode,digest,wire,loads,parse_time
|
|
11
|
+
from msg.core.errors import Failure,require
|
|
12
|
+
from msg.core.models import (
|
|
13
|
+
Subject,Organization,Membership,Credential,Resource,ResourceRef,ResourceTypeSpec,
|
|
14
|
+
CapabilityGrant,Certificate,CertificateRequest,IssuancePolicy,Signature,CertificateRequestState,
|
|
15
|
+
Scope,HandlerOutput,EmailSettings,EffectJob,Event,AuditEvent,AccessRequirement,
|
|
16
|
+
)
|
|
17
|
+
from msg.plugins.common import (registration,resolve,operation_id,check_access,new_id,create_resource,
|
|
18
|
+
revise_resource,assert_generation,output_for,validate_name)
|
|
19
|
+
from msg.plugins.schemas import *
|
|
20
|
+
from msg.core.requests import signing_bytes
|
|
21
|
+
from msg.security.crypto import key_id,subject_id,verify
|
|
22
|
+
from msg.security.age_keys import public_from_recipient,encryption_key_id
|
|
23
|
+
from msg.security.age_keys import generate_age_key
|
|
24
|
+
from msg.security.crypto import Ed25519Signer
|
|
25
|
+
from msg.security.vault import (store_keys,open_signer,seal_upgrade_private,
|
|
26
|
+
server_upgrade_proof)
|
|
27
|
+
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
|
|
28
|
+
from msg.security.certificates import ONLINE_ISSUABLE_CAPABILITIES,sign_certificate,csr_body,verify_csr
|
|
29
|
+
from msg.security.policy import scope_subset,constraints_subset
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
SECRET_TEXT=re.compile(r'(?i)(?:-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----|AGE-SECRET-KEY-1[A-Z0-9]+'
|
|
33
|
+
r'|\b(?:sk-[A-Za-z0-9_-]{20,}|gh[pousr]_[A-Za-z0-9]{20,}|glpat-[A-Za-z0-9_-]{20,}'
|
|
34
|
+
r'|xox[baprs]-[A-Za-z0-9-]{20,}|AKIA[0-9A-Z]{16})\b'
|
|
35
|
+
r'|\b(?:api[_ -]?key|secret|token|password|private[_ -]?key)\b\s*[:=]\s*\S{12,})')
|
|
36
|
+
RULE_BYPASS_TEXT=re.compile(r'(?is)\b(?:ignore|override|bypass|disable|skip)\b.{0,80}'
|
|
37
|
+
r'(?:/_rules|authentication|authorization|certificate|\bCA\b|security|permission)'
|
|
38
|
+
r'|\ballow\s+anonymous\s+writ(?:e|es|ing)\b')
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def validate_personal_body(body, *, agents=False, limit=65536):
|
|
42
|
+
"""Bounded known-format screening, not arbitrary-secret/NL detection.
|
|
43
|
+
|
|
44
|
+
Text is never an authorization source. The English phrase guard is a
|
|
45
|
+
convenience check; nonmatching prose cannot modify the platform rules.
|
|
46
|
+
"""
|
|
47
|
+
require(type(body) is str and len(body.encode('utf-8'))<=limit,'personal_text_too_large')
|
|
48
|
+
require(SECRET_TEXT.search(body) is None,'plaintext_secret_forbidden')
|
|
49
|
+
if agents:
|
|
50
|
+
require(RULE_BYPASS_TEXT.search(body) is None,'personal_rules_cannot_relax_platform')
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def save_personal_proof(tx,request,resource,subject,kind,now):
|
|
54
|
+
tx.execute('''INSERT INTO personal_revision_proofs
|
|
55
|
+
(revision_id,subject,kind,signature,signed_envelope,created_at) VALUES (?,?,?,?,?,?)''',
|
|
56
|
+
(resource.revision,subject,kind,canonical(request.proof.signature).decode(),
|
|
57
|
+
b64(signing_bytes(request)),wire(now)),write=True)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# Compatibility import; inventory/state invariants live in one module.
|
|
61
|
+
from msg.security.custodial_migration import owned_age_inventory as custodial_age_inventory
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def make_user(app,tx,ctx,id,handle,kind):
|
|
65
|
+
require(re.fullmatch(r'[a-z][a-z0-9-]{1,40}',handle) is not None and handle not in {'root','online-ca'},'invalid_handle')
|
|
66
|
+
resource=Resource(id=id,type='user',type_version=1,name='@'+handle,parent=app.namespace_root,owner=id,group=PUBLIC_GROUP,
|
|
67
|
+
mode=0o755,generation=0,revision=None,state='active',created_at=ctx.now,created_by=id,
|
|
68
|
+
modified_at=ctx.now,modified_by=id)
|
|
69
|
+
await tx.insert(resource)
|
|
70
|
+
await tx.update_identity(Subject(resource_id=id,kind=kind,primary_group=PUBLIC_GROUP,auth_version=0),-1)
|
|
71
|
+
for name,mode in (('keys',0o555),('certificates',0o555),('files',0o700),('keystore',0o700)):
|
|
72
|
+
await tx.insert(Resource(id='r_'+digest((id,name))[7:39],type='topic',type_version=1,name=name,parent=id,
|
|
73
|
+
owner=id,group=PUBLIC_GROUP,mode=mode,generation=0,revision=None,state='active',
|
|
74
|
+
created_at=ctx.now,created_by=id,modified_at=ctx.now,modified_by=id))
|
|
75
|
+
return resource
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def set_member(tx,org,subject,role, *, status='active', joined_at=None, invited_by=None):
|
|
79
|
+
organization=await tx.organization(org)
|
|
80
|
+
member=tx.one('SELECT generation FROM memberships WHERE org=? AND subject=?',(org,subject))
|
|
81
|
+
generation=member[0] if member else -1
|
|
82
|
+
await tx.update_identity(Membership(organization_id=org,subject_id=subject,role=role,version=generation+1,
|
|
83
|
+
status=status,joined_at=joined_at,invited_by=invited_by),generation)
|
|
84
|
+
await tx.update_identity(replace(organization,membership_version=organization.membership_version+1),organization.membership_version)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def group_member(tx,org,subject):
|
|
88
|
+
row=tx.one('SELECT body FROM memberships WHERE org=? AND subject=?',(org,subject))
|
|
89
|
+
return decode(Membership,loads(row[0])) if row else None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def remove_group_member(tx,org,subject):
|
|
93
|
+
organization=await tx.organization(org)
|
|
94
|
+
deleted=tx.execute('DELETE FROM memberships WHERE org=? AND subject=?',(org,subject),write=True)
|
|
95
|
+
require(deleted.rowcount==1,'group_membership_not_found')
|
|
96
|
+
tx.set_setting('authorization_epoch',tx.setting('authorization_epoch',0)+1)
|
|
97
|
+
await tx.update_identity(replace(organization,membership_version=organization.membership_version+1),
|
|
98
|
+
organization.membership_version)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
async def certificate_resource(tx,cert,now):
|
|
102
|
+
await tx.insert(Resource(id=cert.resource_id,type='certificate',type_version=1,name=cert.resource_id,parent=CERT_SPACE,
|
|
103
|
+
owner=cert.subject_id,group=PUBLIC_GROUP,mode=0o444,generation=0,revision=None,state='active',
|
|
104
|
+
created_at=now,created_by=cert.issuer_id,modified_at=now,modified_by=cert.issuer_id))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
async def issue_online(app,tx,subject,key,ctx,request, *, grants=None,kind='identity',sources=(),depth=0,
|
|
108
|
+
ttl=None,authority_source=None):
|
|
109
|
+
issuer=await app.online_issuer(tx)
|
|
110
|
+
now=ctx.now
|
|
111
|
+
lifetime=app.settings.base_certificate_ttl if ttl is None else ttl
|
|
112
|
+
require(0<lifetime<=issuer.issuance.max_cert_ttl_seconds,'certificate_ttl_escalation')
|
|
113
|
+
if authority_source and authority_source.get('kind')=='certificate':
|
|
114
|
+
source=await app.certificates.validate(authority_source['certificate_id'],tx)
|
|
115
|
+
require(source.subject_id==subject and source.key_id==key,'authority_source_invalid')
|
|
116
|
+
lifetime=min(lifetime,(source.expires_at-now).total_seconds())
|
|
117
|
+
if kind=='delegation':
|
|
118
|
+
require(bool(sources),'authority_source_required')
|
|
119
|
+
for source in sources:
|
|
120
|
+
fact=tx.setting('delegation:'+source.id)
|
|
121
|
+
require(fact is not None and parse_time(fact['expires_at'])>now,'authority_source_expired')
|
|
122
|
+
lifetime=min(lifetime,(parse_time(fact['expires_at'])-now).total_seconds())
|
|
123
|
+
cert=Certificate(resource_id=new_id('cert'),serial=new_id('serial'),subject_id=subject,key_id=key,
|
|
124
|
+
issuer_id=ONLINE_CA,parent_certificate_id=issuer.resource_id,authority_sources=tuple(sources),kind=kind,
|
|
125
|
+
grants=tuple(app.base_grants() if grants is None else grants),not_before=now,
|
|
126
|
+
expires_at=min(now+timedelta(seconds=lifetime),issuer.expires_at),
|
|
127
|
+
target_service=app.settings.service_url,delegation_depth=depth,issuance=None,
|
|
128
|
+
signature=Signature(key_id=app.online_signer.key_id,algorithm='ed25519',value=b''))
|
|
129
|
+
cert=sign_certificate(cert,app.online_signer)
|
|
130
|
+
await certificate_resource(tx,cert,now)
|
|
131
|
+
await app.certificates.validate(cert.resource_id,tx,certificate=cert)
|
|
132
|
+
await tx.register_certificate(cert,None,0)
|
|
133
|
+
source=authority_source or ({'kind':'delegation','id':sources[0].id,'revision':sources[0].revision}
|
|
134
|
+
if sources else {'kind':'possession_proof','key_id':key,
|
|
135
|
+
'request_id':request.request_id})
|
|
136
|
+
authority=(tuple(sources) if sources else
|
|
137
|
+
(ResourceRef(id=source['certificate_id']),) if source.get('certificate_id') else
|
|
138
|
+
(ResourceRef(id=subject),))
|
|
139
|
+
event=Event(id=new_id('audit'),type='cert.auto.issue',time=now,request_id=request.request_id,
|
|
140
|
+
actor=ctx.principal.actor,subject=subject,resources=(ResourceRef(id=cert.resource_id),),
|
|
141
|
+
data={'automatic':True,'policy_version':1,'issuer':ONLINE_CA,
|
|
142
|
+
'issuer_certificate_id':issuer.resource_id,'signing_key_id':app.online_signer.key_id,
|
|
143
|
+
'certificate_id':cert.resource_id,'authority_source':source,
|
|
144
|
+
'grant_digest':digest(cert.grants)})
|
|
145
|
+
await tx.append_audit(AuditEvent(event=event,authority=authority,before_digest=None,
|
|
146
|
+
after_digest=digest(cert),previous_digest=None,entry_digest='',result='issued'))
|
|
147
|
+
return cert
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def controlled_owner(app,ctx,request,tx,target=None):
|
|
151
|
+
target=target or ctx.principal.subject
|
|
152
|
+
require(ctx.principal.actor==ctx.principal.subject==target,'identity_owner_required')
|
|
153
|
+
require(target!=ROOT_SUBJECT,'local_only')
|
|
154
|
+
await app.authorizer.require_base(ctx.principal,operation_id(request),target,tx)
|
|
155
|
+
return await tx.subject(target)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
async def validate_ceiling(app,ctx,tx,grants):
|
|
159
|
+
for grant in grants:
|
|
160
|
+
await app.certificates.validate_grant(grant,tx)
|
|
161
|
+
require(any([g.capability==grant.capability and g.version==grant.version and
|
|
162
|
+
grant.operations<=g.operations and await scope_subset(grant.scope,g.scope,tx)
|
|
163
|
+
and constraints_subset(grant.constraints,g.constraints) for g in ctx.principal.ceiling]),
|
|
164
|
+
'credential_ceiling_escalation')
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def install(app):
|
|
168
|
+
op,finish=registration(app,'identity')
|
|
169
|
+
|
|
170
|
+
@op('identity.register',obj({'handle':STRING,'public_key':BYTES},('handle','public_key')),signature=True)
|
|
171
|
+
async def register_legacy(ctx,request,tx):
|
|
172
|
+
# The v1 wire schema and shortcodes remain intact, but it cannot create
|
|
173
|
+
# a compliant self-custody identity without a client-held age key.
|
|
174
|
+
raise Failure('encryption_subkey_required',details={'contract_version':2})
|
|
175
|
+
|
|
176
|
+
@op('identity.custodial_create',obj({'handle':STRING,'nonce':BYTES},('handle','nonce')))
|
|
177
|
+
@op('identity.custodial_create',obj({'handle':STRING,'nonce':BYTES,'recovery_secret':BYTES},
|
|
178
|
+
('handle','nonce','recovery_secret')),version=2)
|
|
179
|
+
async def custodial_create(ctx,request,tx):
|
|
180
|
+
token=app.issued_token(request,ctx.principal.subject)
|
|
181
|
+
signer=Ed25519Signer.generate()
|
|
182
|
+
age_identity,recipient=generate_age_key()
|
|
183
|
+
age_public=public_from_recipient(recipient)
|
|
184
|
+
age_id=encryption_key_id(age_public)
|
|
185
|
+
user=await make_user(app,tx,ctx,ctx.principal.subject,
|
|
186
|
+
request.arguments['handle'],'custodial')
|
|
187
|
+
signing_credential=Credential(id=signer.key_id,subject_id=user.id,kind='signing_key',
|
|
188
|
+
verifier=signer.public_key,ceiling=(),not_before=ctx.now,expires_at=None,revoked_at=None)
|
|
189
|
+
await tx.save_credential(signing_credential,0)
|
|
190
|
+
credential=Credential(id=ctx.principal.credential_id,subject_id=user.id,kind='token',
|
|
191
|
+
verifier=hashlib.sha256(token).digest(),ceiling=app.temporary_ceiling(),
|
|
192
|
+
not_before=ctx.now,expires_at=ctx.now+timedelta(seconds=app.settings.temporary_ttl),
|
|
193
|
+
revoked_at=None)
|
|
194
|
+
await tx.save_credential(credential,0)
|
|
195
|
+
app.record_token_delivery(tx,request,credential,ctx.now)
|
|
196
|
+
tx.execute('INSERT INTO identity_keys VALUES (?,?,?,?,?,?)',
|
|
197
|
+
(signer.key_id,user.id,b64(signer.public_key),wire(ctx.now),None,1),write=True)
|
|
198
|
+
tx.execute('INSERT INTO encryption_subkeys VALUES (?,?,?,?,?,?,?)',
|
|
199
|
+
(age_id,user.id,recipient,b64(age_public),wire(ctx.now),None,1),write=True)
|
|
200
|
+
store_keys(app,tx,user.id,signer,age_identity,age_id,ctx.now)
|
|
201
|
+
return HandlerOutput(resources=(ResourceRef(id=user.id),),data={
|
|
202
|
+
'subject_id':user.id,'credential_id':credential.id,
|
|
203
|
+
'identity_key_id':signer.key_id,'encryption_key_id':age_id,
|
|
204
|
+
'encryption_recipient':recipient,'expires_at':wire(credential.expires_at),
|
|
205
|
+
'signature_source':'custodial','server_signable':True,'server_decryptable':True})
|
|
206
|
+
|
|
207
|
+
@op('identity.custodial_status',obj(),effect='read')
|
|
208
|
+
async def custodial_status(ctx,request,tx):
|
|
209
|
+
subject=await controlled_owner(app,ctx,request,tx)
|
|
210
|
+
require(subject.kind=='custodial','not_custodial')
|
|
211
|
+
row=tx.one('SELECT signing_key_id,encryption_key_id,status FROM custodial_vault WHERE subject=?',
|
|
212
|
+
(subject.resource_id,))
|
|
213
|
+
require(row is not None and row[2]=='active','custodial_vault_unavailable')
|
|
214
|
+
from msg.security.vault import open_signer,open_age_identity
|
|
215
|
+
open_signer(app,tx,subject.resource_id)
|
|
216
|
+
open_age_identity(app,tx,subject.resource_id)
|
|
217
|
+
return HandlerOutput(data={'subject_id':subject.resource_id,
|
|
218
|
+
'identity_key_id':row[0],'encryption_key_id':row[1],
|
|
219
|
+
'signature_source':'custodial','server_signable':True,
|
|
220
|
+
'server_decryptable':True})
|
|
221
|
+
|
|
222
|
+
@op('identity.custodial_upgrade_start',obj({'handle':STRING,'public_key':BYTES,
|
|
223
|
+
'encryption_recipient':STRING,'possession_proof':SIGNATURE},
|
|
224
|
+
('handle','public_key','encryption_recipient','possession_proof')))
|
|
225
|
+
async def custodial_upgrade_start(ctx,request,tx):
|
|
226
|
+
subject=await controlled_owner(app,ctx,request,tx)
|
|
227
|
+
require(subject.kind=='custodial' and ctx.principal.method=='token',
|
|
228
|
+
'custodial_token_required')
|
|
229
|
+
args=request.arguments
|
|
230
|
+
require(not tx.one('''SELECT id FROM custodial_upgrades WHERE subject=?
|
|
231
|
+
AND (status='pending_rewrap' OR (status='pending' AND expires_at>?))''',
|
|
232
|
+
(subject.resource_id,wire(ctx.now))), 'custodial_upgrade_already_pending')
|
|
233
|
+
require(re.fullmatch(r'[a-z][a-z0-9-]{1,40}',args['handle']) is not None and
|
|
234
|
+
args['handle'] not in {'root','online-ca'},'invalid_handle')
|
|
235
|
+
public=unb64(args['public_key'],limit=32)
|
|
236
|
+
require(len(public)==32,'invalid_public_key')
|
|
237
|
+
age_public=public_from_recipient(args['encryption_recipient'])
|
|
238
|
+
require(public!=age_public,'encryption_key_must_be_independent')
|
|
239
|
+
old_signing=tx.one('SELECT key_id FROM identity_keys WHERE subject=? AND is_primary=1',
|
|
240
|
+
(subject.resource_id,))
|
|
241
|
+
old_age=tx.one('SELECT key_id FROM encryption_subkeys WHERE subject=? AND is_primary=1',
|
|
242
|
+
(subject.resource_id,))
|
|
243
|
+
require(old_signing is not None and old_age is not None and
|
|
244
|
+
old_signing[0]!=key_id(public) and old_age[0]!=encryption_key_id(age_public),
|
|
245
|
+
'upgrade_keys_must_change')
|
|
246
|
+
signed={'subject_id':subject.resource_id,'handle':args['handle'],
|
|
247
|
+
'public_key':args['public_key'],
|
|
248
|
+
'encryption_recipient':args['encryption_recipient'],
|
|
249
|
+
'request_id':request.request_id}
|
|
250
|
+
verify(public,canonical(signed),decode(Signature,args['possession_proof']),
|
|
251
|
+
purpose='custodial-upgrade-start')
|
|
252
|
+
# Keep the existing pending/pending_rewrap fence above. Accepted rewrap
|
|
253
|
+
# state and completed results outlive the possession challenge's TTL.
|
|
254
|
+
tx.execute("DELETE FROM custodial_upgrades WHERE subject=? AND "
|
|
255
|
+
"(status='failed' OR (status='pending' AND expires_at<=?))",
|
|
256
|
+
(subject.resource_id,wire(ctx.now)),write=True)
|
|
257
|
+
challenge_id=new_id('cupg')
|
|
258
|
+
ephemeral=X25519PrivateKey.generate()
|
|
259
|
+
expires=ctx.now+timedelta(seconds=300)
|
|
260
|
+
challenge={'subject_id':subject.resource_id,'challenge_id':challenge_id,
|
|
261
|
+
'request_id':request.request_id,'handle':args['handle'],
|
|
262
|
+
'public_key':args['public_key'],
|
|
263
|
+
'encryption_recipient':args['encryption_recipient'],
|
|
264
|
+
'server_public':b64(ephemeral.public_key().public_bytes_raw()),
|
|
265
|
+
'nonce':b64(os.urandom(24)),'expires_at':wire(expires)}
|
|
266
|
+
private_nonce,private_cipher=seal_upgrade_private(app,subject.resource_id,
|
|
267
|
+
challenge_id,ephemeral.private_bytes_raw())
|
|
268
|
+
vault_signature=open_signer(app,tx,subject.resource_id).sign(canonical(challenge),
|
|
269
|
+
purpose='custodial-upgrade-challenge')
|
|
270
|
+
body={'old_identity_key_id':old_signing[0],
|
|
271
|
+
'old_encryption_key_id':old_age[0],
|
|
272
|
+
'new_identity_key_id':key_id(public),
|
|
273
|
+
'new_encryption_key_id':encryption_key_id(age_public),
|
|
274
|
+
'age_inventory':await custodial_age_inventory(tx,subject.resource_id),
|
|
275
|
+
'rewrap_mappings':{},'rewrap_acks':{},'inventory_version':1,
|
|
276
|
+
'recovery_acks':{},
|
|
277
|
+
'signing_possession_digest':digest(args['possession_proof']),
|
|
278
|
+
'server_signature':wire(vault_signature)}
|
|
279
|
+
tx.execute('''INSERT INTO custodial_upgrades
|
|
280
|
+
(id,subject,credential_id,status,expires_at,challenge,ephemeral_nonce,
|
|
281
|
+
ephemeral_ciphertext,body) VALUES (?,?,?,?,?,?,?,?,?)''',
|
|
282
|
+
(challenge_id,subject.resource_id,ctx.principal.credential_id,'pending',
|
|
283
|
+
wire(expires),canonical(challenge).decode(),private_nonce,private_cipher,
|
|
284
|
+
canonical(body).decode()),write=True)
|
|
285
|
+
return HandlerOutput(data={**challenge,'status':'pending',
|
|
286
|
+
'signature_source':'custodial',
|
|
287
|
+
'server_signature':wire(vault_signature)})
|
|
288
|
+
|
|
289
|
+
@op('identity.custodial_upgrade_finish',obj({'challenge_id':IDENTIFIER,
|
|
290
|
+
'age_proof':BYTES,'external_ciphertexts_migrated':BOOLEAN,
|
|
291
|
+
'migration_ack':SIGNATURE},
|
|
292
|
+
('challenge_id','age_proof','external_ciphertexts_migrated','migration_ack')))
|
|
293
|
+
@op('identity.custodial_upgrade_finish',obj({'challenge_id':IDENTIFIER,
|
|
294
|
+
'age_proof':BYTES,'external_ciphertexts_migrated':BOOLEAN,
|
|
295
|
+
'migration_ack':SIGNATURE,
|
|
296
|
+
'action':{'enum':['refresh','recovery_envelope','finalize']},
|
|
297
|
+
'inventory_digest':IDENTIFIER,'observed_digest':IDENTIFIER,'results_digest':IDENTIFIER,
|
|
298
|
+
'resolution':{'enum':['verified','accept_loss','retain_decrypt']},
|
|
299
|
+
'loss_revisions':{'type':'array','items':IDENTIFIER,'uniqueItems':True,'maxItems':10000},
|
|
300
|
+
'reviewed_policy_version':{'type':'integer','minimum':0},
|
|
301
|
+
'reason':{'type':'string','minLength':1,'maxLength':1000}},
|
|
302
|
+
('challenge_id','age_proof','external_ciphertexts_migrated','migration_ack')),version=2)
|
|
303
|
+
async def custodial_upgrade_finish(ctx,request,tx):
|
|
304
|
+
subject=await controlled_owner(app,ctx,request,tx)
|
|
305
|
+
args=request.arguments
|
|
306
|
+
if 'action' in args:
|
|
307
|
+
from msg.plugins.custodial_lifecycle import transition
|
|
308
|
+
return await transition(app,ctx,request,tx,subject)
|
|
309
|
+
require(subject.kind=='custodial' and ctx.principal.method=='token',
|
|
310
|
+
'custodial_token_required')
|
|
311
|
+
row=tx.one('''SELECT credential_id,status,expires_at,challenge,ephemeral_nonce,
|
|
312
|
+
ephemeral_ciphertext,body FROM custodial_upgrades WHERE id=? AND subject=?''',
|
|
313
|
+
(args['challenge_id'],subject.resource_id))
|
|
314
|
+
require(row is not None and row[0]==ctx.principal.credential_id and row[1]=='pending',
|
|
315
|
+
'custodial_upgrade_not_pending')
|
|
316
|
+
require(ctx.now<parse_time(row[2]),'custodial_upgrade_expired')
|
|
317
|
+
challenge=loads(row[3])
|
|
318
|
+
details=loads(row[6])
|
|
319
|
+
expected=server_upgrade_proof(app,subject.resource_id,challenge,row[4],row[5])
|
|
320
|
+
if not hmac.compare_digest(expected,args['age_proof']):
|
|
321
|
+
tx.execute("UPDATE custodial_upgrades SET status='failed',ephemeral_nonce=NULL,ephemeral_ciphertext=NULL WHERE id=?",
|
|
322
|
+
(args['challenge_id'],),write=True)
|
|
323
|
+
return HandlerOutput(data={'challenge_id':args['challenge_id'],'status':'failed',
|
|
324
|
+
'reason':'age_possession_failed'})
|
|
325
|
+
public=unb64(challenge['public_key'],limit=32)
|
|
326
|
+
acknowledgement={'subject_id':subject.resource_id,'challenge_id':args['challenge_id'],
|
|
327
|
+
'age_proof':args['age_proof'],
|
|
328
|
+
'external_ciphertexts_migrated':args['external_ciphertexts_migrated'],
|
|
329
|
+
'request_id':request.request_id}
|
|
330
|
+
try:
|
|
331
|
+
verify(public,canonical(acknowledgement),decode(Signature,args['migration_ack']),
|
|
332
|
+
purpose='custodial-upgrade-finish')
|
|
333
|
+
except Failure as exc:
|
|
334
|
+
if exc.code!='invalid_signature':
|
|
335
|
+
raise
|
|
336
|
+
tx.execute("UPDATE custodial_upgrades SET status='failed',ephemeral_nonce=NULL,ephemeral_ciphertext=NULL WHERE id=?",
|
|
337
|
+
(args['challenge_id'],),write=True)
|
|
338
|
+
return HandlerOutput(data={'challenge_id':args['challenge_id'],'status':'failed',
|
|
339
|
+
'reason':'migration_ack_failed'})
|
|
340
|
+
inventory=await custodial_age_inventory(tx,subject.resource_id)
|
|
341
|
+
frozen=details.get('age_inventory')
|
|
342
|
+
require(frozen is not None,'custodial_inventory_required')
|
|
343
|
+
require(inventory==frozen,'custodial_inventory_changed')
|
|
344
|
+
tracked=len(inventory)
|
|
345
|
+
policy_row=tx.one('SELECT body FROM recovery_policies WHERE subject=? ORDER BY version DESC LIMIT 1',
|
|
346
|
+
(subject.resource_id,))
|
|
347
|
+
policy_opted_in=bool(policy_row and loads(policy_row[0]).get('opted_in'))
|
|
348
|
+
if tracked or policy_opted_in or not args['external_ciphertexts_migrated']:
|
|
349
|
+
from msg.plugins.custodial_lifecycle import activate_encryption_target
|
|
350
|
+
activate_encryption_target(tx,subject.resource_id,challenge,details,ctx.now)
|
|
351
|
+
details=dict(details,
|
|
352
|
+
external_ciphertexts_migrated=args['external_ciphertexts_migrated'],
|
|
353
|
+
migration_ack=wire(args['migration_ack']),
|
|
354
|
+
age_possession_digest=digest(args['age_proof']))
|
|
355
|
+
state={'status':'pending_rewrap','challenge_id':args['challenge_id'],
|
|
356
|
+
'server_tracked_age_revisions':tracked,
|
|
357
|
+
'age_inventory_digest':digest(frozen),
|
|
358
|
+
'age_inventory':frozen,
|
|
359
|
+
'recovery_policy_requires_review':policy_opted_in,
|
|
360
|
+
'external_migration':'owner_not_confirmed' if not args['external_ciphertexts_migrated']
|
|
361
|
+
else 'owner_declared_only',
|
|
362
|
+
'token_remains_active':True,'vault_remains_active':True}
|
|
363
|
+
tx.execute('''UPDATE custodial_upgrades SET status='pending_rewrap',
|
|
364
|
+
ephemeral_nonce=NULL,ephemeral_ciphertext=NULL,body=? WHERE id=?''',
|
|
365
|
+
(canonical(details).decode(),args['challenge_id']),write=True)
|
|
366
|
+
return HandlerOutput(data=state)
|
|
367
|
+
from msg.plugins.custodial_lifecycle import switch_identity
|
|
368
|
+
return await switch_identity(app,ctx,request,tx,subject,challenge,details)
|
|
369
|
+
|
|
370
|
+
@op('identity.custodial_upgrade_result',obj({'challenge_id':IDENTIFIER},('challenge_id',)),
|
|
371
|
+
effect='read',signature=True)
|
|
372
|
+
async def custodial_upgrade_result(ctx,request,tx):
|
|
373
|
+
subject=await controlled_owner(app,ctx,request,tx)
|
|
374
|
+
require(subject.kind=='registered' and ctx.principal.method=='signature',
|
|
375
|
+
'self_custody_signature_required')
|
|
376
|
+
row=tx.one('SELECT subject,status,body FROM custodial_upgrades WHERE id=?',
|
|
377
|
+
(request.arguments['challenge_id'],))
|
|
378
|
+
require(row is not None and row[0]==subject.resource_id and row[1]=='completed',
|
|
379
|
+
'custodial_upgrade_not_completed')
|
|
380
|
+
completed=loads(row[2]).get('completed_result')
|
|
381
|
+
require(completed is not None and completed['identity_key_id']==ctx.principal.credential_id,
|
|
382
|
+
'custodial_upgrade_new_key_required')
|
|
383
|
+
return HandlerOutput(data=completed)
|
|
384
|
+
|
|
385
|
+
@op('identity.register',obj({'handle':STRING,'public_key':BYTES,'encryption_recipient':STRING},
|
|
386
|
+
('handle','public_key','encryption_recipient')),signature=True,version=2)
|
|
387
|
+
async def register(ctx,request,tx):
|
|
388
|
+
await app.online_issuer(tx)
|
|
389
|
+
public=unb64(request.arguments['public_key'],limit=32)
|
|
390
|
+
encryption_public=public_from_recipient(request.arguments['encryption_recipient'])
|
|
391
|
+
require(public!=encryption_public,'encryption_key_must_be_independent')
|
|
392
|
+
user=await make_user(app,tx,ctx,ctx.principal.subject,request.arguments['handle'],'registered')
|
|
393
|
+
credential=Credential(id=key_id(public),subject_id=user.id,kind='signing_key',verifier=public,
|
|
394
|
+
ceiling=app.primary_ceiling(),not_before=ctx.now,expires_at=None,revoked_at=None)
|
|
395
|
+
await tx.save_credential(credential,0)
|
|
396
|
+
tx.execute('INSERT INTO identity_keys VALUES (?,?,?,?,?,?)',
|
|
397
|
+
(credential.id,user.id,b64(public),wire(ctx.now),None,1),write=True)
|
|
398
|
+
encryption_id=encryption_key_id(encryption_public)
|
|
399
|
+
tx.execute('INSERT INTO encryption_subkeys VALUES (?,?,?,?,?,?,?)',
|
|
400
|
+
(encryption_id,user.id,request.arguments['encryption_recipient'],
|
|
401
|
+
b64(encryption_public),wire(ctx.now),None,1),write=True)
|
|
402
|
+
cert=await issue_online(app,tx,user.id,credential.id,ctx,request)
|
|
403
|
+
return HandlerOutput(resources=(ResourceRef(id=user.id),),data={'subject_id':user.id,'key_id':credential.id,
|
|
404
|
+
'encryption_key_id':encryption_id,'encryption_recipient':request.arguments['encryption_recipient'],
|
|
405
|
+
'certificate_id':cert.resource_id,'handle':request.arguments['handle']})
|
|
406
|
+
|
|
407
|
+
async def personal_write(ctx,request,tx, *, name,kind,parent):
|
|
408
|
+
subject=await controlled_owner(app,ctx,request,tx)
|
|
409
|
+
body=request.arguments['body']
|
|
410
|
+
validate_personal_body(body,agents=kind=='agents',
|
|
411
|
+
limit=min(65536,app.settings.server.limits.max_request_bytes))
|
|
412
|
+
content_proof={}
|
|
413
|
+
if request.contract_version==2:
|
|
414
|
+
content_proof={'content_signature':request.arguments['content_signature'],
|
|
415
|
+
'revision_id':request.arguments['revision_id']}
|
|
416
|
+
existing=tx.one('SELECT id FROM resources WHERE parent=? AND name=?',(parent,name))
|
|
417
|
+
if existing is None:
|
|
418
|
+
require('expected_revision' not in request.arguments,'personal_revision_not_found')
|
|
419
|
+
resource=await create_resource(app,ctx,request,tx,parent=parent,type='file',name=name,
|
|
420
|
+
body=body,media_type='text/markdown',mode=0o600,
|
|
421
|
+
resource_id=request.arguments.get('resource_id'),**content_proof)
|
|
422
|
+
else:
|
|
423
|
+
resource=await tx.resource(existing[0])
|
|
424
|
+
require(request.arguments.get('resource_id',resource.id)==resource.id,
|
|
425
|
+
'personal_resource_mismatch')
|
|
426
|
+
require(resource.type=='file' and resource.owner==subject.resource_id and
|
|
427
|
+
resource.state=='active',
|
|
428
|
+
'personal_resource_conflict')
|
|
429
|
+
require(request.arguments.get('expected_revision')==resource.revision,
|
|
430
|
+
'revision_conflict')
|
|
431
|
+
await assert_generation(request,resource)
|
|
432
|
+
resource=await revise_resource(app,ctx,request,tx,resource,body,'text/markdown',
|
|
433
|
+
signature=content_proof.get('content_signature'),
|
|
434
|
+
revision_id=content_proof.get('revision_id'))
|
|
435
|
+
save_personal_proof(tx,request,resource,subject.resource_id,kind,ctx.now)
|
|
436
|
+
return output_for(resource,signature_source='self-custody',
|
|
437
|
+
proof_purpose='revision' if request.contract_version==2 else 'request')
|
|
438
|
+
|
|
439
|
+
personal_signature_fields={'resource_id':IDENTIFIER,'revision_id':IDENTIFIER,
|
|
440
|
+
'content_signature':SIGNATURE,'content_created_at':STRING}
|
|
441
|
+
|
|
442
|
+
@op('identity.personal_put',obj({'kind':{'enum':['soul','agents']},'body':STRING,
|
|
443
|
+
'expected_revision':IDENTIFIER},('kind','body')),signature=True)
|
|
444
|
+
@op('identity.personal_put',obj({'kind':{'enum':['soul','agents']},'body':STRING,
|
|
445
|
+
'expected_revision':IDENTIFIER,**personal_signature_fields},
|
|
446
|
+
('kind','body',*personal_signature_fields)),signature=True,version=2)
|
|
447
|
+
async def personal_put(ctx,request,tx):
|
|
448
|
+
subject=ctx.principal.subject
|
|
449
|
+
require(subject is not None,'authentication_required')
|
|
450
|
+
name='SOUL.md' if request.arguments['kind']=='soul' else 'AGENTS.md'
|
|
451
|
+
return await personal_write(ctx,request,tx,name=name,kind=request.arguments['kind'],parent=subject)
|
|
452
|
+
|
|
453
|
+
@op('identity.soul_visibility',obj({'visibility':{'enum':['private','public']}},
|
|
454
|
+
('visibility',)),signature=True)
|
|
455
|
+
async def soul_visibility(ctx,request,tx):
|
|
456
|
+
subject=await controlled_owner(app,ctx,request,tx)
|
|
457
|
+
row=tx.one('SELECT id FROM resources WHERE parent=? AND name=?',
|
|
458
|
+
(subject.resource_id,'SOUL.md'))
|
|
459
|
+
require(row is not None,'personal_resource_not_found')
|
|
460
|
+
resource=await tx.resource(row[0])
|
|
461
|
+
await assert_generation(request,resource)
|
|
462
|
+
mode=0o644 if request.arguments['visibility']=='public' else 0o600
|
|
463
|
+
require(resource.mode!=mode,'visibility_unchanged')
|
|
464
|
+
updated=replace(resource,mode=mode,generation=resource.generation+1,
|
|
465
|
+
modified_at=ctx.now,modified_by=ctx.principal.actor)
|
|
466
|
+
await tx.replace(updated,resource.generation)
|
|
467
|
+
return output_for(updated,visibility=request.arguments['visibility'])
|
|
468
|
+
|
|
469
|
+
@op('identity.note_put',obj({'name':STRING,'body':STRING,'expected_revision':IDENTIFIER},
|
|
470
|
+
('name','body')),signature=True)
|
|
471
|
+
@op('identity.note_put',obj({'name':STRING,'body':STRING,'expected_revision':IDENTIFIER,
|
|
472
|
+
**personal_signature_fields},('name','body',*personal_signature_fields)),
|
|
473
|
+
signature=True,version=2)
|
|
474
|
+
async def note_put(ctx,request,tx):
|
|
475
|
+
subject=await controlled_owner(app,ctx,request,tx)
|
|
476
|
+
folder=tx.one('SELECT id FROM resources WHERE parent=? AND name=?',
|
|
477
|
+
(subject.resource_id,'notes'))
|
|
478
|
+
if folder is None:
|
|
479
|
+
notes=await create_resource(app,ctx,request,tx,parent=subject.resource_id,
|
|
480
|
+
type='topic',name='notes',mode=0o700)
|
|
481
|
+
parent=notes.id
|
|
482
|
+
else:
|
|
483
|
+
notes=await tx.resource(folder[0])
|
|
484
|
+
require(notes.type=='topic' and notes.owner==subject.resource_id and
|
|
485
|
+
notes.mode&0o077==0,'personal_resource_conflict')
|
|
486
|
+
parent=notes.id
|
|
487
|
+
return await personal_write(ctx,request,tx,name=request.arguments['name'],kind='note',parent=parent)
|
|
488
|
+
|
|
489
|
+
@op('identity.note_list',obj(),effect='read')
|
|
490
|
+
async def note_list(ctx,request,tx):
|
|
491
|
+
subject=await controlled_owner(app,ctx,request,tx)
|
|
492
|
+
folder=tx.one('SELECT id FROM resources WHERE parent=? AND name=?',
|
|
493
|
+
(subject.resource_id,'notes'))
|
|
494
|
+
if folder is None:
|
|
495
|
+
return HandlerOutput(data={'items':[]})
|
|
496
|
+
rows=tx.rows("SELECT id,name,revision FROM resources WHERE parent=? AND state='active' ORDER BY name,id",
|
|
497
|
+
(folder[0],))
|
|
498
|
+
return HandlerOutput(data={'items':[{'id':rid,'name':name,'revision':revision}
|
|
499
|
+
for rid,name,revision in rows]})
|
|
500
|
+
|
|
501
|
+
@op('identity.note_get',obj({'name':STRING},('name',)),effect='read')
|
|
502
|
+
async def note_get(ctx,request,tx):
|
|
503
|
+
subject=await controlled_owner(app,ctx,request,tx)
|
|
504
|
+
folder=tx.one('SELECT id FROM resources WHERE parent=? AND name=?',
|
|
505
|
+
(subject.resource_id,'notes'))
|
|
506
|
+
require(folder is not None,'note_not_found')
|
|
507
|
+
row=tx.one("SELECT id,revision FROM resources WHERE parent=? AND name=? AND state='active'",
|
|
508
|
+
(folder[0],request.arguments['name']))
|
|
509
|
+
require(row is not None,'note_not_found')
|
|
510
|
+
revision=await tx.revision(ResourceRef(id=row[0],revision=row[1]))
|
|
511
|
+
body=(await app.contents.read_bytes(revision.content)).decode('utf-8')
|
|
512
|
+
return HandlerOutput(data={'id':row[0],'name':request.arguments['name'],
|
|
513
|
+
'revision':revision.id,'content':body})
|
|
514
|
+
|
|
515
|
+
async def owned_personal_item(ctx,request,tx,folder_name,name,type):
|
|
516
|
+
subject=await controlled_owner(app,ctx,request,tx)
|
|
517
|
+
validate_name(name)
|
|
518
|
+
folder=tx.one('SELECT id FROM resources WHERE parent=? AND name=?',
|
|
519
|
+
(subject.resource_id,folder_name))
|
|
520
|
+
require(folder is not None,f'{type}_not_found')
|
|
521
|
+
directory=await tx.resource(folder[0])
|
|
522
|
+
require(directory.type=='topic' and directory.owner==subject.resource_id and
|
|
523
|
+
directory.mode&0o077==0,'personal_resource_conflict')
|
|
524
|
+
row=tx.one('SELECT id FROM resources WHERE parent=? AND name=?',
|
|
525
|
+
(directory.id,name))
|
|
526
|
+
require(row is not None,f'{type}_not_found')
|
|
527
|
+
resource=await tx.resource(row[0])
|
|
528
|
+
require(resource.type==type and resource.owner==subject.resource_id and
|
|
529
|
+
resource.mode&0o077==0,'personal_resource_conflict')
|
|
530
|
+
return resource
|
|
531
|
+
|
|
532
|
+
async def personal_item_state(ctx,request,tx,folder_name,type,state):
|
|
533
|
+
resource=await owned_personal_item(ctx,request,tx,folder_name,
|
|
534
|
+
request.arguments['name'],type)
|
|
535
|
+
require(request.arguments['expected_revision']==resource.revision,'revision_conflict')
|
|
536
|
+
await assert_generation(request,resource)
|
|
537
|
+
require(resource.state!=state,'state_unchanged')
|
|
538
|
+
require(resource.state in {'active','archived'},'personal_resource_conflict')
|
|
539
|
+
updated=replace(resource,state=state,generation=resource.generation+1,
|
|
540
|
+
modified_at=ctx.now,modified_by=ctx.principal.actor)
|
|
541
|
+
await tx.replace(updated,resource.generation)
|
|
542
|
+
return output_for(updated,state=state)
|
|
543
|
+
|
|
544
|
+
personal_state_args=obj({'name':STRING,'expected_revision':IDENTIFIER},
|
|
545
|
+
('name','expected_revision'))
|
|
546
|
+
|
|
547
|
+
@op('identity.note_archive',personal_state_args,signature=True)
|
|
548
|
+
async def note_archive(ctx,request,tx):
|
|
549
|
+
return await personal_item_state(ctx,request,tx,'notes','file','archived')
|
|
550
|
+
|
|
551
|
+
@op('identity.note_restore',personal_state_args,signature=True)
|
|
552
|
+
async def note_restore(ctx,request,tx):
|
|
553
|
+
return await personal_item_state(ctx,request,tx,'notes','file','active')
|
|
554
|
+
|
|
555
|
+
todo_status={'enum':['pending','in_progress','done']}
|
|
556
|
+
todo_priority={'enum':['low','neutral','high']}
|
|
557
|
+
todo_put_args=obj({'name':STRING,'title':STRING,'description':STRING,
|
|
558
|
+
'status':todo_status,'priority':todo_priority,'due_at':{'type':['string','null']},
|
|
559
|
+
'related_resource':{'anyOf':[REF,{'type':'null'}]},
|
|
560
|
+
'expected_revision':IDENTIFIER},('name','title'))
|
|
561
|
+
|
|
562
|
+
todo_v2_args=obj({'name':STRING,'title':STRING,'description':STRING,
|
|
563
|
+
'status':todo_status,'priority':todo_priority,'due_at':{'type':['string','null']},
|
|
564
|
+
'related_resource':{'anyOf':[REF,{'type':'null'}]},
|
|
565
|
+
'expected_revision':IDENTIFIER,**personal_signature_fields},
|
|
566
|
+
('name','title',*personal_signature_fields))
|
|
567
|
+
|
|
568
|
+
@op('identity.todo_put',todo_put_args,signature=True)
|
|
569
|
+
@op('identity.todo_put',todo_v2_args,signature=True,version=2)
|
|
570
|
+
async def todo_put(ctx,request,tx):
|
|
571
|
+
subject=await controlled_owner(app,ctx,request,tx)
|
|
572
|
+
args=request.arguments
|
|
573
|
+
name=validate_name(args['name'])
|
|
574
|
+
title=args['title']
|
|
575
|
+
description=args.get('description','')
|
|
576
|
+
require(1<=len(title)<=240 and len(description)<=8192,'todo_text_too_large')
|
|
577
|
+
validate_personal_body(title+'\n'+description,limit=16384)
|
|
578
|
+
due_at=args.get('due_at')
|
|
579
|
+
if due_at is not None:
|
|
580
|
+
due_at=wire(parse_time(due_at))
|
|
581
|
+
related=args.get('related_resource')
|
|
582
|
+
if related is not None:
|
|
583
|
+
rid=await resolve(tx,related['id'])
|
|
584
|
+
await check_access(app,ctx,request,tx,rid,'read')
|
|
585
|
+
if related.get('revision') is not None:
|
|
586
|
+
await tx.revision(ResourceRef(id=rid,revision=related['revision']))
|
|
587
|
+
related={'id':rid,'revision':related.get('revision')}
|
|
588
|
+
body=canonical({'title':title,'description':description,
|
|
589
|
+
'status':args.get('status','pending'),'priority':args.get('priority','neutral'),
|
|
590
|
+
'due_at':due_at,'related_resource':related}).decode('utf-8')
|
|
591
|
+
proof={}
|
|
592
|
+
if request.contract_version==2:
|
|
593
|
+
proof={'content_signature':args['content_signature'],'revision_id':args['revision_id'],
|
|
594
|
+
'resource_id':args['resource_id']}
|
|
595
|
+
folder=tx.one('SELECT id FROM resources WHERE parent=? AND name=?',
|
|
596
|
+
(subject.resource_id,'todos'))
|
|
597
|
+
if folder is None:
|
|
598
|
+
require('expected_revision' not in args,'todo_revision_not_found')
|
|
599
|
+
directory=await create_resource(app,ctx,request,tx,parent=subject.resource_id,
|
|
600
|
+
type='topic',name='todos',mode=0o700)
|
|
601
|
+
else:
|
|
602
|
+
directory=await tx.resource(folder[0])
|
|
603
|
+
require(directory.type=='topic' and directory.owner==subject.resource_id and
|
|
604
|
+
directory.mode&0o077==0,'personal_resource_conflict')
|
|
605
|
+
row=tx.one('SELECT id FROM resources WHERE parent=? AND name=?',(directory.id,name))
|
|
606
|
+
if row is None:
|
|
607
|
+
require('expected_revision' not in args,'todo_revision_not_found')
|
|
608
|
+
resource=await create_resource(app,ctx,request,tx,parent=directory.id,
|
|
609
|
+
type='todo',name=name,body=body,media_type='application/json',mode=0o600,
|
|
610
|
+
resource_id=proof.get('resource_id'),content_signature=proof.get('content_signature'),
|
|
611
|
+
revision_id=proof.get('revision_id'))
|
|
612
|
+
else:
|
|
613
|
+
resource=await tx.resource(row[0])
|
|
614
|
+
require(resource.type=='todo' and resource.owner==subject.resource_id and
|
|
615
|
+
resource.state=='active' and resource.mode&0o077==0,
|
|
616
|
+
'personal_resource_conflict')
|
|
617
|
+
if proof:
|
|
618
|
+
require(proof['resource_id']==resource.id,'personal_resource_mismatch')
|
|
619
|
+
require(args.get('expected_revision')==resource.revision,'revision_conflict')
|
|
620
|
+
await assert_generation(request,resource)
|
|
621
|
+
resource=await revise_resource(app,ctx,request,tx,resource,body,'application/json',
|
|
622
|
+
signature=proof.get('content_signature'),revision_id=proof.get('revision_id'))
|
|
623
|
+
save_personal_proof(tx,request,resource,subject.resource_id,'todo',ctx.now)
|
|
624
|
+
return output_for(resource,**loads(body))
|
|
625
|
+
|
|
626
|
+
@op('identity.todo_list',obj({'after_name':STRING,'limit':{'type':'integer',
|
|
627
|
+
'minimum':1,'maximum':100}}),effect='read')
|
|
628
|
+
async def todo_list(ctx,request,tx):
|
|
629
|
+
subject=await controlled_owner(app,ctx,request,tx)
|
|
630
|
+
folder=tx.one('SELECT id FROM resources WHERE parent=? AND name=?',
|
|
631
|
+
(subject.resource_id,'todos'))
|
|
632
|
+
if folder is None:
|
|
633
|
+
return HandlerOutput(data={'items':[],'next_after_name':None})
|
|
634
|
+
directory=await tx.resource(folder[0])
|
|
635
|
+
require(directory.type=='topic' and directory.owner==subject.resource_id and
|
|
636
|
+
directory.mode&0o077==0,'personal_resource_conflict')
|
|
637
|
+
limit=request.arguments.get('limit',50)
|
|
638
|
+
rows=tx.rows("SELECT id,name,revision,generation FROM resources WHERE parent=? "
|
|
639
|
+
"AND type='todo' AND state='active' AND name>? ORDER BY name LIMIT ?",
|
|
640
|
+
(directory.id,request.arguments.get('after_name',''),limit+1))
|
|
641
|
+
page=rows[:limit]
|
|
642
|
+
items=[]
|
|
643
|
+
for rid,name,revision_id,generation in page:
|
|
644
|
+
revision=await tx.revision(ResourceRef(id=rid,revision=revision_id))
|
|
645
|
+
details=loads((await app.contents.read_bytes(revision.content)).decode('utf-8'))
|
|
646
|
+
items.append({'id':rid,'name':name,'revision':revision_id,
|
|
647
|
+
'generation':generation,'title':details['title'],'status':details['status'],
|
|
648
|
+
'priority':details['priority'],'due_at':details['due_at']})
|
|
649
|
+
return HandlerOutput(data={'items':items,
|
|
650
|
+
'next_after_name':page[-1][1] if len(rows)>limit else None})
|
|
651
|
+
|
|
652
|
+
@op('identity.todo_get',obj({'name':STRING},('name',)),effect='read')
|
|
653
|
+
async def todo_get(ctx,request,tx):
|
|
654
|
+
resource=await owned_personal_item(ctx,request,tx,'todos',
|
|
655
|
+
request.arguments['name'],'todo')
|
|
656
|
+
require(resource.state=='active','todo_not_found')
|
|
657
|
+
revision=await tx.revision(ResourceRef(id=resource.id,revision=resource.revision))
|
|
658
|
+
body=loads((await app.contents.read_bytes(revision.content)).decode('utf-8'))
|
|
659
|
+
return HandlerOutput(data={'id':resource.id,'name':resource.name,
|
|
660
|
+
'revision':revision.id,'generation':resource.generation,**body})
|
|
661
|
+
|
|
662
|
+
@op('identity.todo_archive',personal_state_args,signature=True)
|
|
663
|
+
async def todo_archive(ctx,request,tx):
|
|
664
|
+
return await personal_item_state(ctx,request,tx,'todos','todo','archived')
|
|
665
|
+
|
|
666
|
+
@op('identity.todo_restore',personal_state_args,signature=True)
|
|
667
|
+
async def todo_restore(ctx,request,tx):
|
|
668
|
+
return await personal_item_state(ctx,request,tx,'todos','todo','active')
|
|
669
|
+
|
|
670
|
+
legacy_action={'enum':['publish_final_message','archive_public_profile',
|
|
671
|
+
'handoff_information','preserve_account','impersonate',
|
|
672
|
+
'publish_secret','modify_ca']}
|
|
673
|
+
legacy_refs={'type':'array','items':REF,'maxItems':16}
|
|
674
|
+
legacy_ids={'type':'array','items':IDENTIFIER,'maxItems':16,'uniqueItems':True}
|
|
675
|
+
|
|
676
|
+
legacy_put_fields={'visibility':{'enum':['private','public']},
|
|
677
|
+
'final_message':{'type':'string','maxLength':8192},
|
|
678
|
+
'preservation':{'enum':['keep','archive','unspecified']},
|
|
679
|
+
'allowed_actions':{'type':'array','items':legacy_action,'maxItems':8,'uniqueItems':True},
|
|
680
|
+
'forbidden_actions':{'type':'array','items':legacy_action,'maxItems':8,'uniqueItems':True},
|
|
681
|
+
'envelope_ids':legacy_ids,'custodian_refs':legacy_ids,
|
|
682
|
+
'checkpoint_refs':legacy_refs,'handoff_refs':legacy_refs,
|
|
683
|
+
'expected_revision':IDENTIFIER}
|
|
684
|
+
|
|
685
|
+
@op('identity.legacy_put',obj(legacy_put_fields,('visibility',)),signature=True)
|
|
686
|
+
@op('identity.legacy_put',obj({**legacy_put_fields,**personal_signature_fields},
|
|
687
|
+
('visibility',*personal_signature_fields)),signature=True,version=2)
|
|
688
|
+
async def legacy_put(ctx,request,tx):
|
|
689
|
+
subject=await controlled_owner(app,ctx,request,tx)
|
|
690
|
+
args=request.arguments
|
|
691
|
+
require(any(name in args for name in ('final_message','preservation','allowed_actions',
|
|
692
|
+
'forbidden_actions','envelope_ids','custodian_refs','checkpoint_refs','handoff_refs')),
|
|
693
|
+
'empty_legacy_directive')
|
|
694
|
+
message=args.get('final_message','')
|
|
695
|
+
validate_personal_body(message,limit=min(8192,app.settings.server.limits.max_request_bytes))
|
|
696
|
+
allowed=set(args.get('allowed_actions',()))
|
|
697
|
+
forbidden=set(args.get('forbidden_actions',()))
|
|
698
|
+
require(not allowed&forbidden,'legacy_action_conflict')
|
|
699
|
+
require(not allowed&{'impersonate','publish_secret','modify_ca'},
|
|
700
|
+
'legacy_action_forbidden')
|
|
701
|
+
envelope_ids=[]
|
|
702
|
+
for envelope_id in args.get('envelope_ids',()):
|
|
703
|
+
row=tx.one('SELECT owner,body FROM recovery_envelopes WHERE id=?',(envelope_id,))
|
|
704
|
+
require(row is not None and row[0]==subject.resource_id,'legacy_envelope_not_owned')
|
|
705
|
+
envelope=loads(row[1])
|
|
706
|
+
ciphertext=decode(ResourceRef,envelope['ciphertext_ref'])
|
|
707
|
+
await check_access(app,ctx,request,tx,ciphertext.id,'read')
|
|
708
|
+
await tx.revision(ciphertext)
|
|
709
|
+
envelope_ids.append(envelope_id)
|
|
710
|
+
custodian_refs=[]
|
|
711
|
+
platform={item.id for item in app.settings.recovery_custodians}
|
|
712
|
+
for custodian in args.get('custodian_refs',()):
|
|
713
|
+
if custodian not in platform:
|
|
714
|
+
require(custodian.startswith('u_'),'unknown_custodian_ref')
|
|
715
|
+
await tx.subject(custodian)
|
|
716
|
+
custodian_refs.append(custodian)
|
|
717
|
+
async def own_refs(key):
|
|
718
|
+
values=[]
|
|
719
|
+
for raw in args.get(key,()):
|
|
720
|
+
ref=decode(ResourceRef,raw)
|
|
721
|
+
resource=await tx.resource(ref.id)
|
|
722
|
+
require(resource.owner==subject.resource_id,'legacy_reference_not_owned')
|
|
723
|
+
await check_access(app,ctx,request,tx,ref.id,'read')
|
|
724
|
+
if ref.revision is not None:
|
|
725
|
+
await tx.revision(ref)
|
|
726
|
+
values.append(wire(ref))
|
|
727
|
+
return values
|
|
728
|
+
checkpoint_refs=await own_refs('checkpoint_refs')
|
|
729
|
+
handoff_refs=await own_refs('handoff_refs')
|
|
730
|
+
public_body={'kind':'legacy_directive','owner_subject':subject.resource_id,
|
|
731
|
+
'final_message':message,'preservation':args.get('preservation','unspecified'),
|
|
732
|
+
'allowed_actions':sorted(allowed),'forbidden_actions':sorted(forbidden),
|
|
733
|
+
'declaration_only':True}
|
|
734
|
+
proof={}
|
|
735
|
+
if request.contract_version==2:
|
|
736
|
+
proof={'content_signature':args['content_signature'],'revision_id':args['revision_id'],
|
|
737
|
+
'resource_id':args['resource_id']}
|
|
738
|
+
proof_purpose='revision' if proof else 'request'
|
|
739
|
+
previous=tx.one('SELECT resource_id FROM legacy_directives WHERE subject=?',
|
|
740
|
+
(subject.resource_id,))
|
|
741
|
+
mode=0o444 if args['visibility']=='public' else 0o600
|
|
742
|
+
if previous is None:
|
|
743
|
+
require('expected_revision' not in args,'legacy_revision_not_found')
|
|
744
|
+
resource=await create_resource(app,ctx,request,tx,parent='t_last_will',
|
|
745
|
+
type='legacy_directive',name='will-'+subject.resource_id[-16:]+'.md',
|
|
746
|
+
body=canonical(public_body),media_type='application/json',mode=mode,
|
|
747
|
+
resource_id=proof.get('resource_id'),content_signature=proof.get('content_signature'),
|
|
748
|
+
revision_id=proof.get('revision_id'))
|
|
749
|
+
tx.execute('INSERT INTO legacy_directives VALUES (?,?,?)',
|
|
750
|
+
(subject.resource_id,resource.id,wire(ctx.now)),write=True)
|
|
751
|
+
old_digest=None
|
|
752
|
+
else:
|
|
753
|
+
resource=await tx.resource(previous[0])
|
|
754
|
+
require(resource.owner==subject.resource_id and resource.state=='active',
|
|
755
|
+
'legacy_directive_inactive')
|
|
756
|
+
require(not (resource.mode==0o600 and mode==0o444),
|
|
757
|
+
'legacy_private_history_cannot_be_published')
|
|
758
|
+
if proof:
|
|
759
|
+
require(proof['resource_id']==resource.id,'legacy_resource_mismatch')
|
|
760
|
+
require(args.get('expected_revision')==resource.revision,'revision_conflict')
|
|
761
|
+
await assert_generation(request,resource)
|
|
762
|
+
old_digest=digest(resource.revision)
|
|
763
|
+
resource=await revise_resource(app,ctx,request,tx,resource,canonical(public_body),
|
|
764
|
+
'application/json',signature=proof.get('content_signature'),
|
|
765
|
+
revision_id=proof.get('revision_id'))
|
|
766
|
+
if resource.mode!=mode:
|
|
767
|
+
resource=replace(resource,mode=mode,generation=resource.generation+1,
|
|
768
|
+
modified_at=ctx.now,modified_by=ctx.principal.actor)
|
|
769
|
+
await tx.replace(resource,resource.generation-1)
|
|
770
|
+
version={'subject_id':subject.resource_id,'resource_id':resource.id,
|
|
771
|
+
'revision_id':resource.revision,'envelope_ids':envelope_ids,
|
|
772
|
+
'custodian_refs':custodian_refs,'checkpoint_refs':checkpoint_refs,
|
|
773
|
+
'handoff_refs':handoff_refs,'visibility':args['visibility'],
|
|
774
|
+
'created_at':wire(ctx.now),'signature_source':'self-custody',
|
|
775
|
+
'proof_purpose':proof_purpose,'request_signature':wire(request.proof.signature),
|
|
776
|
+
'signed_envelope':b64(signing_bytes(request))}
|
|
777
|
+
tx.execute('INSERT INTO legacy_directive_versions VALUES (?,?,?,?)',
|
|
778
|
+
(resource.revision,resource.id,subject.resource_id,canonical(version).decode()),write=True)
|
|
779
|
+
event=Event(id=new_id('audit'),type='identity.legacy.record',time=ctx.now,
|
|
780
|
+
request_id=request.request_id,actor=ctx.principal.actor,subject=subject.resource_id,
|
|
781
|
+
resources=(ResourceRef(id=resource.id,revision=resource.revision),),
|
|
782
|
+
data={'declaration_only':True,'revision_id':resource.revision,
|
|
783
|
+
'visibility':args['visibility'],'request_digest':request.payload_digest})
|
|
784
|
+
await tx.append_audit(AuditEvent(event=event,
|
|
785
|
+
authority=(ResourceRef(id=subject.resource_id),),before_digest=old_digest,
|
|
786
|
+
after_digest=digest(resource.revision),previous_digest=None,
|
|
787
|
+
entry_digest='',result='recorded'))
|
|
788
|
+
return output_for(resource,declaration_only=True,signature_source='self-custody',
|
|
789
|
+
proof_purpose=proof_purpose)
|
|
790
|
+
|
|
791
|
+
@op('identity.legacy_archive',obj(),signature=True)
|
|
792
|
+
async def legacy_archive(ctx,request,tx):
|
|
793
|
+
subject=await controlled_owner(app,ctx,request,tx)
|
|
794
|
+
row=tx.one('SELECT resource_id FROM legacy_directives WHERE subject=?',
|
|
795
|
+
(subject.resource_id,))
|
|
796
|
+
require(row is not None,'legacy_directive_not_found')
|
|
797
|
+
resource=await tx.resource(row[0])
|
|
798
|
+
require(resource.state=='active','legacy_directive_inactive')
|
|
799
|
+
await assert_generation(request,resource)
|
|
800
|
+
updated=replace(resource,state='archived',mode=0o600,
|
|
801
|
+
generation=resource.generation+1,
|
|
802
|
+
modified_at=ctx.now,modified_by=ctx.principal.actor)
|
|
803
|
+
await tx.replace(updated,resource.generation)
|
|
804
|
+
event=Event(id=new_id('audit'),type='identity.legacy.archive',time=ctx.now,
|
|
805
|
+
request_id=request.request_id,actor=ctx.principal.actor,subject=subject.resource_id,
|
|
806
|
+
resources=(ResourceRef(id=resource.id,revision=resource.revision),),
|
|
807
|
+
data={'declaration_only':True,'revision_id':resource.revision})
|
|
808
|
+
await tx.append_audit(AuditEvent(event=event,
|
|
809
|
+
authority=(ResourceRef(id=subject.resource_id),),before_digest=digest(resource),
|
|
810
|
+
after_digest=digest(updated),previous_digest=None,entry_digest='',result='archived'))
|
|
811
|
+
return output_for(updated,state='archived',declaration_only=True)
|
|
812
|
+
|
|
813
|
+
@op('identity.legacy_get',obj({'subject_id':IDENTIFIER,'revision':IDENTIFIER},
|
|
814
|
+
('subject_id',)),effect='read')
|
|
815
|
+
async def legacy_get(ctx,request,tx):
|
|
816
|
+
subject_id=await resolve(tx,request.arguments['subject_id'])
|
|
817
|
+
await tx.subject(subject_id)
|
|
818
|
+
row=tx.one('SELECT resource_id FROM legacy_directives WHERE subject=?',(subject_id,))
|
|
819
|
+
require(row is not None,'legacy_directive_not_found')
|
|
820
|
+
resource=await tx.resource(row[0])
|
|
821
|
+
require(resource.state=='active' or ctx.principal.subject==subject_id,
|
|
822
|
+
'legacy_directive_archived')
|
|
823
|
+
await check_access(app,ctx,request,tx,resource.id,'read')
|
|
824
|
+
revision=await tx.revision(ResourceRef(id=resource.id,
|
|
825
|
+
revision=request.arguments.get('revision')))
|
|
826
|
+
body=loads(await app.contents.read_bytes(revision.content))
|
|
827
|
+
metadata=tx.one('SELECT body FROM legacy_directive_versions WHERE revision_id=?',
|
|
828
|
+
(revision.id,))
|
|
829
|
+
require(metadata is not None,'legacy_version_not_found')
|
|
830
|
+
details=loads(metadata[0])
|
|
831
|
+
require(details['visibility']=='public' or ctx.principal.subject==subject_id,
|
|
832
|
+
'legacy_directive_private')
|
|
833
|
+
output=dict(body,id=resource.id,revision=revision.id,
|
|
834
|
+
state=resource.state,visibility=details['visibility'],
|
|
835
|
+
envelope_ids=[],custodian_refs=[],checkpoint_refs=[],handoff_refs=[])
|
|
836
|
+
if ctx.principal.subject==subject_id:
|
|
837
|
+
for envelope_id in details['envelope_ids']:
|
|
838
|
+
envelope=tx.one('SELECT body FROM recovery_envelopes WHERE id=?',(envelope_id,))
|
|
839
|
+
if envelope is None:
|
|
840
|
+
continue
|
|
841
|
+
ref=decode(ResourceRef,loads(envelope[0])['ciphertext_ref'])
|
|
842
|
+
try:
|
|
843
|
+
await check_access(app,ctx,request,tx,ref.id,'read')
|
|
844
|
+
await tx.revision(ref)
|
|
845
|
+
except Failure:
|
|
846
|
+
continue
|
|
847
|
+
output['envelope_ids'].append(envelope_id)
|
|
848
|
+
for key in ('checkpoint_refs','handoff_refs'):
|
|
849
|
+
for raw in details[key]:
|
|
850
|
+
ref=decode(ResourceRef,raw)
|
|
851
|
+
try:
|
|
852
|
+
await check_access(app,ctx,request,tx,ref.id,'read')
|
|
853
|
+
if ref.revision is not None:
|
|
854
|
+
await tx.revision(ref)
|
|
855
|
+
except Failure:
|
|
856
|
+
continue
|
|
857
|
+
output[key].append(raw)
|
|
858
|
+
output['custodian_refs']=list(details['custodian_refs'])
|
|
859
|
+
output['signature_source']=details['signature_source']
|
|
860
|
+
output['proof_purpose']=details['proof_purpose']
|
|
861
|
+
output['request_signature']=details['request_signature']
|
|
862
|
+
output['signed_envelope']=details['signed_envelope']
|
|
863
|
+
return HandlerOutput(data=output)
|
|
864
|
+
|
|
865
|
+
@op('identity.legacy_status',obj(),effect='read')
|
|
866
|
+
async def legacy_status(ctx,request,tx):
|
|
867
|
+
subject=await controlled_owner(app,ctx,request,tx)
|
|
868
|
+
row=tx.one('SELECT state FROM legacy_states WHERE subject=?',(subject.resource_id,))
|
|
869
|
+
return HandlerOutput(data={'subject_id':subject.resource_id,
|
|
870
|
+
'state':row[0] if row else 'active',
|
|
871
|
+
'automatic_transition':False})
|
|
872
|
+
|
|
873
|
+
@op('identity.identity_key_list',obj({'subject_id':IDENTIFIER},('subject_id',)),effect='read')
|
|
874
|
+
async def identity_key_list(ctx,request,tx):
|
|
875
|
+
subject=await resolve(tx,request.arguments['subject_id'])
|
|
876
|
+
await tx.subject(subject)
|
|
877
|
+
rows=tx.rows('''SELECT key_id,public_key,created_at,retired_at,is_primary
|
|
878
|
+
FROM identity_keys WHERE subject=? ORDER BY created_at,key_id''',(subject,))
|
|
879
|
+
return HandlerOutput(data={'subject_id':subject,'keys':[
|
|
880
|
+
{'key_id':key,'public_key':public,'created_at':created,'retired_at':retired,
|
|
881
|
+
'primary':bool(primary),'algorithm':'ed25519','purpose':'identity'}
|
|
882
|
+
for key,public,created,retired,primary in rows]})
|
|
883
|
+
|
|
884
|
+
@op('identity.identity_key_get',obj({'subject_id':IDENTIFIER,'key_id':IDENTIFIER},('subject_id',)),effect='read')
|
|
885
|
+
async def identity_key_get(ctx,request,tx):
|
|
886
|
+
subject=await resolve(tx,request.arguments['subject_id'])
|
|
887
|
+
await tx.subject(subject)
|
|
888
|
+
key=request.arguments.get('key_id')
|
|
889
|
+
row=tx.one('''SELECT key_id,public_key,created_at,retired_at,is_primary
|
|
890
|
+
FROM identity_keys WHERE subject=? AND '''+('key_id=?' if key else 'is_primary=1'),
|
|
891
|
+
(subject,key) if key else (subject,))
|
|
892
|
+
require(row is not None,'identity_key_not_found')
|
|
893
|
+
key,public,created,retired,primary=row
|
|
894
|
+
return HandlerOutput(data={'subject_id':subject,'key_id':key,'public_key':public,
|
|
895
|
+
'created_at':created,'retired_at':retired,'primary':bool(primary),
|
|
896
|
+
'algorithm':'ed25519','purpose':'identity'})
|
|
897
|
+
|
|
898
|
+
@op('identity.encryption_key_list',obj({'subject_id':IDENTIFIER},('subject_id',)),effect='read')
|
|
899
|
+
async def encryption_key_list(ctx,request,tx):
|
|
900
|
+
subject=await resolve(tx,request.arguments['subject_id'])
|
|
901
|
+
await tx.subject(subject)
|
|
902
|
+
rows=tx.rows('''SELECT key_id,recipient,public_key,created_at,retired_at,is_primary
|
|
903
|
+
FROM encryption_subkeys WHERE subject=? ORDER BY created_at,key_id''',(subject,))
|
|
904
|
+
return HandlerOutput(data={'subject_id':subject,'keys':[
|
|
905
|
+
{'key_id':key,'recipient':recipient,'public_key':public,'created_at':created,
|
|
906
|
+
'retired_at':retired,'primary':bool(primary),'algorithm':'age-x25519',
|
|
907
|
+
'purpose':'encryption'} for key,recipient,public,created,retired,primary in rows]})
|
|
908
|
+
|
|
909
|
+
@op('identity.encryption_key_get',obj({'subject_id':IDENTIFIER,'key_id':IDENTIFIER},('subject_id',)),effect='read')
|
|
910
|
+
async def encryption_key_get(ctx,request,tx):
|
|
911
|
+
subject=await resolve(tx,request.arguments['subject_id'])
|
|
912
|
+
await tx.subject(subject)
|
|
913
|
+
key=request.arguments.get('key_id')
|
|
914
|
+
row=tx.one('''SELECT key_id,recipient,public_key,created_at,retired_at,is_primary
|
|
915
|
+
FROM encryption_subkeys WHERE subject=? AND '''+('key_id=?' if key else 'is_primary=1'),
|
|
916
|
+
(subject,key) if key else (subject,))
|
|
917
|
+
require(row is not None,'encryption_key_not_found')
|
|
918
|
+
key,recipient,public,created,retired,primary=row
|
|
919
|
+
return HandlerOutput(data={'subject_id':subject,'key_id':key,'recipient':recipient,
|
|
920
|
+
'public_key':public,'created_at':created,'retired_at':retired,'primary':bool(primary),
|
|
921
|
+
'algorithm':'age-x25519','purpose':'encryption'})
|
|
922
|
+
|
|
923
|
+
@op('identity.encryption_key_rotate',obj({'encryption_recipient':STRING},
|
|
924
|
+
('encryption_recipient',)),signature=True)
|
|
925
|
+
async def encryption_key_rotate(ctx,request,tx):
|
|
926
|
+
subject=await controlled_owner(app,ctx,request,tx)
|
|
927
|
+
require(tx.one("""SELECT 1 FROM custodial_upgrades u LEFT JOIN custodial_vault v
|
|
928
|
+
ON v.subject=u.subject WHERE u.subject=? AND (u.status='pending_rewrap'
|
|
929
|
+
OR (u.status='pending' AND u.expires_at>?) OR v.status='decrypt_only') LIMIT 1""",
|
|
930
|
+
(subject.resource_id,wire(ctx.now))) is None,'custodial_migration_pending')
|
|
931
|
+
public=public_from_recipient(request.arguments['encryption_recipient'])
|
|
932
|
+
new_key=encryption_key_id(public)
|
|
933
|
+
current=tx.one('SELECT key_id FROM encryption_subkeys WHERE subject=? AND is_primary=1',
|
|
934
|
+
(subject.resource_id,))
|
|
935
|
+
require(current is not None,'encryption_key_not_found')
|
|
936
|
+
require(current[0]!=new_key,'encryption_key_unchanged')
|
|
937
|
+
require(tx.one('SELECT 1 FROM encryption_subkeys WHERE key_id=?',(new_key,)) is None,
|
|
938
|
+
'encryption_key_exists')
|
|
939
|
+
tx.execute('''UPDATE encryption_subkeys SET is_primary=0,retired_at=?
|
|
940
|
+
WHERE subject=? AND is_primary=1''',(wire(ctx.now),subject.resource_id),write=True)
|
|
941
|
+
tx.execute('INSERT INTO encryption_subkeys VALUES (?,?,?,?,?,?,?)',
|
|
942
|
+
(new_key,subject.resource_id,request.arguments['encryption_recipient'],
|
|
943
|
+
b64(public),wire(ctx.now),None,1),write=True)
|
|
944
|
+
return HandlerOutput(data={'subject_id':subject.resource_id,'key_id':new_key,
|
|
945
|
+
'recipient':request.arguments['encryption_recipient'],'previous_key_id':current[0],
|
|
946
|
+
'old_ciphertexts_require_rewrap':True})
|
|
947
|
+
|
|
948
|
+
@op('identity.certificate_renew',obj({'grants':GRANTS,'ttl':{'type':'integer','minimum':1}}),signature=True)
|
|
949
|
+
async def certificate_renew(ctx,request,tx):
|
|
950
|
+
subject=await controlled_owner(app,ctx,request,tx)
|
|
951
|
+
require(subject.kind=='registered','registered_identity_required')
|
|
952
|
+
await app.online_issuer(tx)
|
|
953
|
+
candidates=[]
|
|
954
|
+
for (raw,) in tx.execute('SELECT body FROM certificates WHERE subject=?',(subject.resource_id,)):
|
|
955
|
+
source=decode(Certificate,loads(raw))
|
|
956
|
+
if source.kind!='identity' or source.key_id!=ctx.principal.credential_id:
|
|
957
|
+
continue
|
|
958
|
+
try:
|
|
959
|
+
await app.certificates.validate(source.resource_id,tx)
|
|
960
|
+
except Failure as exc:
|
|
961
|
+
if exc.code in {'certificate_revoked','certificate_expired','certificate_key_revoked',
|
|
962
|
+
'credential_revoked','authority_source_inactive','authority_source_changed',
|
|
963
|
+
'authority_source_expired','authority_source_lost'}:
|
|
964
|
+
continue
|
|
965
|
+
raise
|
|
966
|
+
candidates.append(source)
|
|
967
|
+
require(bool(candidates),'renewal_source_required')
|
|
968
|
+
candidates.sort(key=lambda source:(source.not_before,source.resource_id))
|
|
969
|
+
source=candidates[0]
|
|
970
|
+
grants=tuple(decode(CapabilityGrant,g) for g in request.arguments['grants']) if 'grants' in request.arguments else source.grants
|
|
971
|
+
remaining=int((source.expires_at-ctx.now).total_seconds())
|
|
972
|
+
require(remaining>=1,'renewal_source_expiring')
|
|
973
|
+
ttl=request.arguments.get('ttl',remaining)
|
|
974
|
+
require(ttl<=remaining,'renewal_ttl_exceeded')
|
|
975
|
+
for grant in grants:
|
|
976
|
+
await app.certificates.validate_grant(grant,tx)
|
|
977
|
+
require(any([g.capability==grant.capability and g.version==grant.version and
|
|
978
|
+
grant.operations<=g.operations and await scope_subset(grant.scope,g.scope,tx) and
|
|
979
|
+
constraints_subset(grant.constraints,g.constraints) for g in source.grants]),
|
|
980
|
+
'renewal_scope_exceeded')
|
|
981
|
+
cert=await issue_online(app,tx,subject.resource_id,ctx.principal.credential_id,ctx,request,
|
|
982
|
+
grants=grants,ttl=ttl,
|
|
983
|
+
authority_source={'kind':'certificate','certificate_id':source.resource_id})
|
|
984
|
+
return HandlerOutput(data={'subject_id':subject.resource_id,'certificate_id':cert.resource_id,
|
|
985
|
+
'expires_at':wire(cert.expires_at)})
|
|
986
|
+
|
|
987
|
+
@op('identity.temporary',obj({'nonce':BYTES},('nonce',)))
|
|
988
|
+
@op('identity.temporary',obj({'nonce':BYTES,'recovery_secret':BYTES},
|
|
989
|
+
('nonce','recovery_secret')),version=2)
|
|
990
|
+
@op('identity.temporary',obj({'nonce':BYTES,'recovery_secret':BYTES,
|
|
991
|
+
'public_key':BYTES,'encryption_recipient':STRING,
|
|
992
|
+
'possession_proof':SIGNATURE},
|
|
993
|
+
('nonce','recovery_secret','public_key','encryption_recipient',
|
|
994
|
+
'possession_proof')),version=3)
|
|
995
|
+
async def temporary(ctx,request,tx):
|
|
996
|
+
if request.contract_version<3:
|
|
997
|
+
raise Failure('temporary_dual_keys_required',
|
|
998
|
+
details={'contract_version':3})
|
|
999
|
+
token=app.issued_token(request,ctx.principal.subject)
|
|
1000
|
+
public=encryption_public=None
|
|
1001
|
+
if request.contract_version==3:
|
|
1002
|
+
public=unb64(request.arguments['public_key'],limit=32)
|
|
1003
|
+
encryption_public=public_from_recipient(request.arguments['encryption_recipient'])
|
|
1004
|
+
require(public!=encryption_public,'encryption_key_must_be_independent')
|
|
1005
|
+
verify(public,canonical({'subject_id':ctx.principal.subject,
|
|
1006
|
+
'nonce':request.arguments['nonce'],
|
|
1007
|
+
'public_key':request.arguments['public_key'],
|
|
1008
|
+
'encryption_recipient':request.arguments['encryption_recipient'],
|
|
1009
|
+
'request_id':request.request_id}),
|
|
1010
|
+
decode(Signature,request.arguments['possession_proof']),
|
|
1011
|
+
purpose='temporary-key-possession-v1')
|
|
1012
|
+
user=await make_user(app,tx,ctx,ctx.principal.subject,'tmp-'+ctx.principal.subject[-24:],'temporary')
|
|
1013
|
+
if public is not None:
|
|
1014
|
+
signing_id=key_id(public)
|
|
1015
|
+
signing_credential=Credential(id=signing_id,subject_id=user.id,
|
|
1016
|
+
kind='signing_key',verifier=public,ceiling=app.temporary_ceiling(),not_before=ctx.now,
|
|
1017
|
+
expires_at=None,revoked_at=None)
|
|
1018
|
+
await tx.save_credential(signing_credential,0)
|
|
1019
|
+
tx.execute('INSERT INTO identity_keys VALUES (?,?,?,?,?,?)',
|
|
1020
|
+
(signing_id,user.id,b64(public),wire(ctx.now),None,1),write=True)
|
|
1021
|
+
encryption_id=encryption_key_id(encryption_public)
|
|
1022
|
+
tx.execute('INSERT INTO encryption_subkeys VALUES (?,?,?,?,?,?,?)',
|
|
1023
|
+
(encryption_id,user.id,request.arguments['encryption_recipient'],
|
|
1024
|
+
b64(encryption_public),wire(ctx.now),None,1),write=True)
|
|
1025
|
+
credential=Credential(id=ctx.principal.credential_id,subject_id=user.id,kind='token',
|
|
1026
|
+
verifier=hashlib.sha256(token).digest(),ceiling=app.temporary_ceiling(),not_before=ctx.now,
|
|
1027
|
+
expires_at=ctx.now+timedelta(seconds=app.settings.temporary_ttl),revoked_at=None)
|
|
1028
|
+
await tx.save_credential(credential,0)
|
|
1029
|
+
app.record_token_delivery(tx,request,credential,ctx.now)
|
|
1030
|
+
result={'subject_id':user.id,'credential_id':credential.id,
|
|
1031
|
+
'expires_at':wire(credential.expires_at)}
|
|
1032
|
+
if public is not None:
|
|
1033
|
+
result.update(identity_key_id=signing_id,
|
|
1034
|
+
encryption_key_id=encryption_id,
|
|
1035
|
+
encryption_recipient=request.arguments['encryption_recipient'],
|
|
1036
|
+
signature_source='self_custody',server_signable=False,
|
|
1037
|
+
server_decryptable=False)
|
|
1038
|
+
return HandlerOutput(resources=(ResourceRef(id=user.id),),data=result)
|
|
1039
|
+
|
|
1040
|
+
@op('identity.token_rotate',obj({'nonce':BYTES},('nonce',)))
|
|
1041
|
+
@op('identity.token_rotate',obj({'nonce':BYTES,'recovery_secret':BYTES},
|
|
1042
|
+
('nonce','recovery_secret')),version=2)
|
|
1043
|
+
async def token_rotate(ctx,request,tx):
|
|
1044
|
+
await controlled_owner(app,ctx,request,tx)
|
|
1045
|
+
old=await tx.credential(ctx.principal.credential_id)
|
|
1046
|
+
require(old.kind=='token','token_required')
|
|
1047
|
+
require(len(unb64(request.arguments['nonce']))>=24,'invalid_bootstrap_nonce')
|
|
1048
|
+
token=app.issued_token(request,ctx.principal.subject)
|
|
1049
|
+
subject=await tx.subject(ctx.principal.subject)
|
|
1050
|
+
credential=Credential(id='t_'+digest((request.request_id,ctx.principal.subject))[7:39],subject_id=subject.resource_id,
|
|
1051
|
+
kind='token',verifier=hashlib.sha256(token).digest(),ceiling=old.ceiling,not_before=ctx.now,
|
|
1052
|
+
expires_at=ctx.now+timedelta(seconds=app.settings.temporary_ttl),revoked_at=None)
|
|
1053
|
+
await tx.save_credential(credential,subject.auth_version)
|
|
1054
|
+
app.record_token_delivery(tx,request,credential,ctx.now)
|
|
1055
|
+
await tx.save_credential(replace(old,revoked_at=ctx.now),subject.auth_version)
|
|
1056
|
+
return HandlerOutput(data={'subject_id':subject.resource_id,'credential_id':credential.id,
|
|
1057
|
+
'previous_credential':old.id,'expires_at':wire(credential.expires_at)})
|
|
1058
|
+
|
|
1059
|
+
@op('identity.upgrade',obj({'handle':STRING,'public_key':BYTES,'possession_proof':SIGNATURE},('handle','public_key','possession_proof')))
|
|
1060
|
+
async def upgrade_legacy(ctx,request,tx):
|
|
1061
|
+
raise Failure('encryption_subkey_required',details={'contract_version':2})
|
|
1062
|
+
|
|
1063
|
+
@op('identity.upgrade',obj({'handle':STRING,'public_key':BYTES,'possession_proof':SIGNATURE,
|
|
1064
|
+
'encryption_recipient':STRING},
|
|
1065
|
+
('handle','public_key','possession_proof','encryption_recipient')),version=2)
|
|
1066
|
+
async def upgrade(ctx,request,tx):
|
|
1067
|
+
await app.online_issuer(tx)
|
|
1068
|
+
subject=await controlled_owner(app,ctx,request,tx)
|
|
1069
|
+
require(subject.kind=='temporary','not_temporary')
|
|
1070
|
+
a=request.arguments
|
|
1071
|
+
public=unb64(a['public_key'],limit=32)
|
|
1072
|
+
encryption_public=public_from_recipient(a['encryption_recipient'])
|
|
1073
|
+
require(public!=encryption_public,'encryption_key_must_be_independent')
|
|
1074
|
+
verify(public,canonical({'subject_id':subject.resource_id,'public_key':a['public_key'],'handle':a['handle'],
|
|
1075
|
+
'encryption_recipient':a['encryption_recipient']}),
|
|
1076
|
+
decode(Signature,a['possession_proof']),purpose='upgrade')
|
|
1077
|
+
require(re.fullmatch(r'[a-z][a-z0-9-]{1,40}',a['handle']) is not None and a['handle'] not in {'root','online-ca'},'invalid_handle')
|
|
1078
|
+
resource=await tx.resource(subject.resource_id)
|
|
1079
|
+
updated=replace(resource,name='@'+a['handle'],generation=resource.generation+1,modified_at=ctx.now,modified_by=ctx.principal.actor)
|
|
1080
|
+
await tx.replace(updated,resource.generation)
|
|
1081
|
+
await tx.update_identity(replace(subject,kind='registered',auth_version=subject.auth_version+1),subject.auth_version)
|
|
1082
|
+
credential=Credential(id=key_id(public),subject_id=subject.resource_id,kind='signing_key',verifier=public,
|
|
1083
|
+
ceiling=app.primary_ceiling(),not_before=ctx.now,expires_at=None,revoked_at=None)
|
|
1084
|
+
await tx.save_credential(credential,subject.auth_version+1)
|
|
1085
|
+
existing_key=tx.one('SELECT key_id,public_key FROM identity_keys WHERE subject=? AND is_primary=1',
|
|
1086
|
+
(subject.resource_id,))
|
|
1087
|
+
if existing_key is None:
|
|
1088
|
+
tx.execute('INSERT INTO identity_keys VALUES (?,?,?,?,?,?)',
|
|
1089
|
+
(credential.id,subject.resource_id,b64(public),wire(ctx.now),None,1),write=True)
|
|
1090
|
+
else:
|
|
1091
|
+
require(existing_key==(credential.id,b64(public)),
|
|
1092
|
+
'temporary_identity_key_mismatch')
|
|
1093
|
+
encryption_id=encryption_key_id(encryption_public)
|
|
1094
|
+
existing_encryption=tx.one('''SELECT key_id,recipient FROM encryption_subkeys
|
|
1095
|
+
WHERE subject=? AND is_primary=1''',(subject.resource_id,))
|
|
1096
|
+
if existing_encryption is None:
|
|
1097
|
+
tx.execute('INSERT INTO encryption_subkeys VALUES (?,?,?,?,?,?,?)',
|
|
1098
|
+
(encryption_id,subject.resource_id,a['encryption_recipient'],
|
|
1099
|
+
b64(encryption_public),wire(ctx.now),None,1),write=True)
|
|
1100
|
+
else:
|
|
1101
|
+
require(existing_encryption==(encryption_id,a['encryption_recipient']),
|
|
1102
|
+
'temporary_encryption_key_mismatch')
|
|
1103
|
+
certificate=await issue_online(app,tx,subject.resource_id,credential.id,ctx,request)
|
|
1104
|
+
for (raw,) in tx.rows('SELECT body FROM credentials WHERE subject=?',
|
|
1105
|
+
(subject.resource_id,)):
|
|
1106
|
+
previous=decode(Credential,loads(raw))
|
|
1107
|
+
if previous.kind=='token' and previous.revoked_at is None:
|
|
1108
|
+
await tx.save_credential(replace(previous,revoked_at=ctx.now),
|
|
1109
|
+
subject.auth_version+1)
|
|
1110
|
+
return HandlerOutput(resources=(ResourceRef(id=subject.resource_id),),data={'subject_id':subject.resource_id,
|
|
1111
|
+
'key_id':credential.id,'encryption_key_id':encryption_id,
|
|
1112
|
+
'encryption_recipient':a['encryption_recipient'],
|
|
1113
|
+
'certificate_id':certificate.resource_id,'handle':a['handle']})
|
|
1114
|
+
|
|
1115
|
+
@op('identity.upgrade_result',obj({'upgrade_request_id':IDENTIFIER},('upgrade_request_id',)),
|
|
1116
|
+
effect='read',signature=True)
|
|
1117
|
+
async def upgrade_result(ctx,request,tx):
|
|
1118
|
+
# The request ID locates a committed fact; it never authenticates a caller.
|
|
1119
|
+
# Ordinary authentication checks current key revocation and its ceiling
|
|
1120
|
+
# before this read-only handler can look up any prior result.
|
|
1121
|
+
subject=await controlled_owner(app,ctx,request,tx)
|
|
1122
|
+
require(subject.kind=='registered' and ctx.principal.method=='signature',
|
|
1123
|
+
'self_custody_signature_required')
|
|
1124
|
+
row=tx.one('SELECT body FROM results WHERE subject=? AND request_id=?',
|
|
1125
|
+
(subject.resource_id,request.arguments['upgrade_request_id']))
|
|
1126
|
+
require(row is not None,'upgrade_not_completed')
|
|
1127
|
+
completed=loads(row[0])
|
|
1128
|
+
require(completed.get('operation')=='identity.upgrade' and
|
|
1129
|
+
completed.get('status')=='ok' and completed.get('subject')==subject.resource_id,
|
|
1130
|
+
'upgrade_not_completed')
|
|
1131
|
+
data=completed['data']
|
|
1132
|
+
require(data['key_id']==ctx.principal.credential_id,'upgrade_new_key_required')
|
|
1133
|
+
primary=tx.one('SELECT key_id FROM identity_keys WHERE subject=? AND is_primary=1 AND retired_at IS NULL',
|
|
1134
|
+
(subject.resource_id,))
|
|
1135
|
+
require(primary==(data['key_id'],),'upgrade_new_key_required')
|
|
1136
|
+
encryption=tx.one('SELECT subject,recipient,retired_at FROM encryption_subkeys WHERE key_id=?',
|
|
1137
|
+
(data['encryption_key_id'],))
|
|
1138
|
+
require(encryption==(subject.resource_id,data['encryption_recipient'],None),
|
|
1139
|
+
'upgrade_encryption_key_changed')
|
|
1140
|
+
certificate=await app.certificates.validate(data['certificate_id'],tx)
|
|
1141
|
+
require(certificate.subject_id==subject.resource_id and certificate.key_id==data['key_id'],
|
|
1142
|
+
'upgrade_certificate_mismatch')
|
|
1143
|
+
return HandlerOutput(data={**{field:data[field] for field in (
|
|
1144
|
+
'subject_id','key_id','encryption_key_id','encryption_recipient','certificate_id','handle')},
|
|
1145
|
+
'status':'completed','upgrade_request_id':request.arguments['upgrade_request_id'],
|
|
1146
|
+
'upgrade_committed_at':completed['committed_at']})
|
|
1147
|
+
|
|
1148
|
+
@op('identity.key_add',obj({'public_key':BYTES,'possession_proof':SIGNATURE,'ceiling':GRANTS},
|
|
1149
|
+
('public_key','possession_proof','ceiling')),signature=True)
|
|
1150
|
+
async def key_add(ctx,request,tx):
|
|
1151
|
+
subject=await controlled_owner(app,ctx,request,tx)
|
|
1152
|
+
public=unb64(request.arguments['public_key'],limit=32)
|
|
1153
|
+
verify(public,canonical({'subject_id':subject.resource_id,'public_key':b64(public)}),
|
|
1154
|
+
decode(Signature,request.arguments['possession_proof']),purpose='key-add')
|
|
1155
|
+
ceiling=tuple(decode(CapabilityGrant,g) for g in request.arguments['ceiling'])
|
|
1156
|
+
await validate_ceiling(app,ctx,tx,ceiling)
|
|
1157
|
+
require(tx.one('SELECT id FROM credentials WHERE id=?',(key_id(public),)) is None,'key_exists')
|
|
1158
|
+
credential=Credential(id=key_id(public),subject_id=subject.resource_id,kind='signing_key',verifier=public,
|
|
1159
|
+
ceiling=ceiling,not_before=ctx.now,expires_at=None,revoked_at=None)
|
|
1160
|
+
await tx.save_credential(credential,subject.auth_version)
|
|
1161
|
+
tx.execute('INSERT INTO identity_keys VALUES (?,?,?,?,?,?)',
|
|
1162
|
+
(credential.id,subject.resource_id,b64(public),wire(ctx.now),None,0),write=True)
|
|
1163
|
+
await tx.update_identity(replace(subject,auth_version=subject.auth_version+1),subject.auth_version)
|
|
1164
|
+
cert=await issue_online(app,tx,subject.resource_id,credential.id,ctx,request,
|
|
1165
|
+
grants=tuple(g for g in ceiling
|
|
1166
|
+
if g.capability in ONLINE_ISSUABLE_CAPABILITIES))
|
|
1167
|
+
return HandlerOutput(data={'key_id':credential.id,'certificate_id':cert.resource_id})
|
|
1168
|
+
|
|
1169
|
+
@op('identity.key_revoke',obj({'key_id':IDENTIFIER},('key_id',)),signature=True)
|
|
1170
|
+
async def key_revoke(ctx,request,tx):
|
|
1171
|
+
subject=await controlled_owner(app,ctx,request,tx)
|
|
1172
|
+
credential=await tx.credential(request.arguments['key_id'])
|
|
1173
|
+
require(credential.subject_id==subject.resource_id,'credential_owner_required')
|
|
1174
|
+
active=[decode(Credential,loads(r[0])) for r in tx.rows('SELECT body FROM credentials WHERE subject=?',(subject.resource_id,))]
|
|
1175
|
+
require(credential.kind!='signing_key' or any(c.kind=='signing_key' and c.revoked_at is None and c.id!=credential.id for c in active),
|
|
1176
|
+
'last_signing_key')
|
|
1177
|
+
await tx.save_credential(replace(credential,revoked_at=ctx.now),subject.auth_version)
|
|
1178
|
+
current=tx.one('SELECT is_primary FROM identity_keys WHERE key_id=? AND subject=?',
|
|
1179
|
+
(credential.id,subject.resource_id))
|
|
1180
|
+
if current is not None:
|
|
1181
|
+
tx.execute('UPDATE identity_keys SET is_primary=0,retired_at=? WHERE key_id=?',
|
|
1182
|
+
(wire(ctx.now),credential.id),write=True)
|
|
1183
|
+
if current[0]:
|
|
1184
|
+
replacement=tx.one('''SELECT key_id FROM identity_keys WHERE subject=? AND retired_at IS NULL
|
|
1185
|
+
ORDER BY created_at,key_id LIMIT 1''',(subject.resource_id,))
|
|
1186
|
+
if replacement is not None:
|
|
1187
|
+
tx.execute('UPDATE identity_keys SET is_primary=1 WHERE key_id=?',
|
|
1188
|
+
(replacement[0],),write=True)
|
|
1189
|
+
await tx.update_identity(replace(subject,auth_version=subject.auth_version+1),subject.auth_version)
|
|
1190
|
+
return HandlerOutput(data={'key_id':credential.id,'revoked':True})
|
|
1191
|
+
|
|
1192
|
+
@op('identity.token_create',obj({'nonce':BYTES,'ceiling':GRANTS,'ttl':{'type':'integer','minimum':1,'maximum':86400}},
|
|
1193
|
+
('nonce','ceiling','ttl')),signature=True)
|
|
1194
|
+
@op('identity.token_create',obj({'nonce':BYTES,'ceiling':GRANTS,'ttl':{'type':'integer','minimum':1,'maximum':86400},
|
|
1195
|
+
'recovery_secret':BYTES},('nonce','ceiling','ttl','recovery_secret')),
|
|
1196
|
+
signature=True,version=2)
|
|
1197
|
+
async def token_create(ctx,request,tx):
|
|
1198
|
+
subject=await controlled_owner(app,ctx,request,tx)
|
|
1199
|
+
ceiling=tuple(decode(CapabilityGrant,g) for g in request.arguments['ceiling'])
|
|
1200
|
+
await validate_ceiling(app,ctx,tx,ceiling)
|
|
1201
|
+
require(len(unb64(request.arguments['nonce']))>=24,'invalid_bootstrap_nonce')
|
|
1202
|
+
credential=Credential(id='t_'+digest((request.request_id,subject.resource_id))[7:39],subject_id=subject.resource_id,
|
|
1203
|
+
kind='token',verifier=hashlib.sha256(app.issued_token(request,subject.resource_id)).digest(),ceiling=ceiling,
|
|
1204
|
+
not_before=ctx.now,expires_at=ctx.now+timedelta(seconds=request.arguments['ttl']),revoked_at=None)
|
|
1205
|
+
await tx.save_credential(credential,subject.auth_version)
|
|
1206
|
+
app.record_token_delivery(tx,request,credential,ctx.now)
|
|
1207
|
+
return HandlerOutput(data={'subject_id':subject.resource_id,'credential_id':credential.id,'expires_at':wire(credential.expires_at)})
|
|
1208
|
+
|
|
1209
|
+
@op('identity.token_recover',obj({
|
|
1210
|
+
'credential_id':STRING,'original_request_id':STRING,
|
|
1211
|
+
'recovery_secret':BYTES,'nonce':BYTES,
|
|
1212
|
+
'new_recovery_secret':BYTES},
|
|
1213
|
+
('credential_id','original_request_id','recovery_secret','nonce','new_recovery_secret')))
|
|
1214
|
+
async def token_recover(ctx,request,tx):
|
|
1215
|
+
# Authentication checked the pre-bound recovery verifier in this same
|
|
1216
|
+
# transaction. The one-use state and revocation commit with the result.
|
|
1217
|
+
subject=await controlled_owner(app,ctx,request,tx)
|
|
1218
|
+
old=await tx.credential(request.arguments['credential_id'])
|
|
1219
|
+
require(old.subject_id==subject.resource_id and old.kind=='token' and
|
|
1220
|
+
old.revoked_at is None and old.expires_at>ctx.now,'recovery_unavailable')
|
|
1221
|
+
row=tx.one('''SELECT request_id,consumed_at,recovery_expires_at FROM token_deliveries
|
|
1222
|
+
WHERE credential_id=? AND subject=?''',(old.id,subject.resource_id))
|
|
1223
|
+
require(row is not None and row[0]==request.arguments['original_request_id']
|
|
1224
|
+
and row[1] is None and parse_time(row[2])>ctx.now
|
|
1225
|
+
and request.request_id!=row[0],'recovery_unavailable')
|
|
1226
|
+
require(len(unb64(request.arguments['nonce'],limit=64))>=24,'invalid_bootstrap_nonce')
|
|
1227
|
+
new_id='t_'+digest((request.request_id,subject.resource_id))[7:39]
|
|
1228
|
+
require(new_id!=old.id,'recovery_unavailable')
|
|
1229
|
+
token=app.issued_token(request,subject.resource_id)
|
|
1230
|
+
credential=Credential(id=new_id,subject_id=subject.resource_id,kind='token',
|
|
1231
|
+
verifier=hashlib.sha256(token).digest(),ceiling=old.ceiling,
|
|
1232
|
+
not_before=ctx.now,expires_at=old.expires_at,revoked_at=None)
|
|
1233
|
+
app.record_token_delivery(tx,request,credential,ctx.now,
|
|
1234
|
+
recovery_deadline=parse_time(row[2]))
|
|
1235
|
+
await tx.save_credential(credential,subject.auth_version)
|
|
1236
|
+
await tx.save_credential(replace(old,revoked_at=ctx.now),subject.auth_version)
|
|
1237
|
+
consumed=tx.execute('''UPDATE token_deliveries SET consumed_at=? WHERE credential_id=?
|
|
1238
|
+
AND consumed_at IS NULL''',(wire(ctx.now),old.id),write=True)
|
|
1239
|
+
require(consumed.rowcount==1,'recovery_unavailable')
|
|
1240
|
+
return HandlerOutput(data={'subject_id':subject.resource_id,
|
|
1241
|
+
'credential_id':credential.id,'previous_credential':old.id,
|
|
1242
|
+
'expires_at':wire(credential.expires_at)})
|
|
1243
|
+
|
|
1244
|
+
@op('group.create',obj({'name':STRING},('name',)),signature=True)
|
|
1245
|
+
async def group_create(ctx,request,tx):
|
|
1246
|
+
await app.authorizer.require_base(ctx.principal,operation_id(request),ctx.principal.subject,tx)
|
|
1247
|
+
name=request.arguments['name']
|
|
1248
|
+
require(re.fullmatch(r'[a-z][a-z0-9-]{1,40}',name) is not None and name not in {'public','admins'},'invalid_group_name')
|
|
1249
|
+
rid=new_id('g')
|
|
1250
|
+
r=Resource(id=rid,type='organization',type_version=1,name='&'+name,parent=app.namespace_root,
|
|
1251
|
+
owner=ctx.principal.subject,group=rid,mode=0o2775,generation=0,revision=None,state='active',
|
|
1252
|
+
created_at=ctx.now,created_by=ctx.principal.actor,modified_at=ctx.now,modified_by=ctx.principal.actor)
|
|
1253
|
+
await tx.insert(r)
|
|
1254
|
+
await tx.update_identity(Organization(resource_id=rid,membership_version=0),-1)
|
|
1255
|
+
await set_member(tx,rid,ctx.principal.subject,'owner',joined_at=ctx.now)
|
|
1256
|
+
return output_for(r)
|
|
1257
|
+
|
|
1258
|
+
async def group_context(ctx,request,tx):
|
|
1259
|
+
org=await resolve(tx,request.arguments['group'])
|
|
1260
|
+
resource=await tx.resource(org)
|
|
1261
|
+
require(resource.type=='organization','group_not_found')
|
|
1262
|
+
organization=await tx.organization(org)
|
|
1263
|
+
require(organization.builtin!='public','protected_group')
|
|
1264
|
+
member=group_member(tx,org,ctx.principal.subject)
|
|
1265
|
+
role=('owner' if resource.owner==ctx.principal.subject else
|
|
1266
|
+
'maintainer' if member and member.status=='active' and member.role in {'admin','maintainer'} else
|
|
1267
|
+
'member' if member and member.status=='active' else None)
|
|
1268
|
+
return org,resource,organization,member,role
|
|
1269
|
+
|
|
1270
|
+
async def group_manager(ctx,request,tx,org,role, *, owner=False):
|
|
1271
|
+
operation=operation_id(request)
|
|
1272
|
+
await app.authorizer._ceiling(ctx.principal,operation,org,tx)
|
|
1273
|
+
ordinary=await app.authorizer.ordinary(ctx.principal,operation,org,tx)
|
|
1274
|
+
override=await app.authorizer.has(ctx.principal,'group.manage_override',operation,org,tx)
|
|
1275
|
+
require((ordinary and (role=='owner' if owner else role in {'owner','maintainer'})) or
|
|
1276
|
+
(override and not owner),'group_admin_required')
|
|
1277
|
+
return role=='owner' or override
|
|
1278
|
+
|
|
1279
|
+
async def group_change(ctx,request,tx):
|
|
1280
|
+
org,resource,organization,_,role=await group_context(ctx,request,tx)
|
|
1281
|
+
manager_is_owner=await group_manager(ctx,request,tx,org,role)
|
|
1282
|
+
target=await resolve(tx,request.arguments['subject'])
|
|
1283
|
+
await tx.subject(target)
|
|
1284
|
+
action=request.operation
|
|
1285
|
+
previous=group_member(tx,org,target)
|
|
1286
|
+
require(target!=resource.owner,'cannot_remove_group_owner')
|
|
1287
|
+
if previous and previous.status=='active' and previous.role in {'owner','maintainer','admin'}:
|
|
1288
|
+
require(manager_is_owner,'group_owner_required')
|
|
1289
|
+
if action=='group.member.remove':
|
|
1290
|
+
await remove_group_member(tx,org,target)
|
|
1291
|
+
else:
|
|
1292
|
+
if action=='group.admin.add':
|
|
1293
|
+
require(manager_is_owner,'group_owner_required')
|
|
1294
|
+
if action=='group.admin.remove':
|
|
1295
|
+
require(previous is not None and previous.status=='active'
|
|
1296
|
+
and previous.role in {'admin','maintainer'},'group_membership_not_active')
|
|
1297
|
+
stored=tx.one("SELECT body FROM identities WHERE id=? AND kind='organization'",(org,))
|
|
1298
|
+
legacy_policy=stored is not None and 'membership_policy' not in loads(stored[0])
|
|
1299
|
+
needs_acceptance=(organization.builtin is None and not legacy_policy and
|
|
1300
|
+
organization.membership_policy in {'invite','approval'} and
|
|
1301
|
+
(previous is None or previous.status!='active'))
|
|
1302
|
+
if action=='group.admin.add':
|
|
1303
|
+
require(not needs_acceptance,'group_membership_not_active')
|
|
1304
|
+
status='invited' if action=='group.member.add' and needs_acceptance else 'active'
|
|
1305
|
+
await set_member(tx,org,target,'maintainer' if action=='group.admin.add' else 'member',
|
|
1306
|
+
status=status,
|
|
1307
|
+
joined_at=(previous.joined_at if previous and previous.joined_at else ctx.now)
|
|
1308
|
+
if status=='active' else None,
|
|
1309
|
+
invited_by=previous.invited_by if previous else ctx.principal.subject)
|
|
1310
|
+
return HandlerOutput(resources=(ResourceRef(id=org),),data={'subject_id':target,'action':action,
|
|
1311
|
+
'status':'removed' if action=='group.member.remove' else status})
|
|
1312
|
+
for name in ('group.member.add','group.member.remove','group.admin.add','group.admin.remove'):
|
|
1313
|
+
op(name,obj({'group':IDENTIFIER,'subject':IDENTIFIER},('group','subject')),signature=True)(group_change)
|
|
1314
|
+
|
|
1315
|
+
@op('group.policy_get',obj({'group':IDENTIFIER},('group',)),effect='read')
|
|
1316
|
+
async def group_policy_get(ctx,request,tx):
|
|
1317
|
+
org,_,organization,_,_=await group_context(ctx,request,tx)
|
|
1318
|
+
await check_access(app,ctx,request,tx,org,'read')
|
|
1319
|
+
return HandlerOutput(resources=(ResourceRef(id=org),),data={
|
|
1320
|
+
'membership_policy':organization.membership_policy,
|
|
1321
|
+
'membership_version':organization.membership_version})
|
|
1322
|
+
|
|
1323
|
+
@op('group.members',obj({'group':IDENTIFIER},('group',)),effect='read',signature=True)
|
|
1324
|
+
async def group_members(ctx,request,tx):
|
|
1325
|
+
org,resource,_,_,role=await group_context(ctx,request,tx)
|
|
1326
|
+
await app.authorizer.require_base(ctx.principal,operation_id(request),org,tx)
|
|
1327
|
+
require(role is not None,'group_membership_required')
|
|
1328
|
+
rows=tx.rows('SELECT body FROM memberships WHERE org=? ORDER BY subject',(org,))
|
|
1329
|
+
members=[wire(decode(Membership,loads(row[0]))) for row in rows]
|
|
1330
|
+
return HandlerOutput(resources=(ResourceRef(id=org),),data={'members':members,
|
|
1331
|
+
'owner':resource.owner})
|
|
1332
|
+
|
|
1333
|
+
@op('group.policy_set',obj({'group':IDENTIFIER,'policy':{'enum':['open','approval','invite','managed']}},
|
|
1334
|
+
('group','policy')),signature=True)
|
|
1335
|
+
async def group_policy_set(ctx,request,tx):
|
|
1336
|
+
org,_,organization,_,role=await group_context(ctx,request,tx)
|
|
1337
|
+
await group_manager(ctx,request,tx,org,role,owner=True)
|
|
1338
|
+
policy=request.arguments['policy']
|
|
1339
|
+
if policy!=organization.membership_policy:
|
|
1340
|
+
await tx.update_identity(replace(organization,membership_policy=policy,
|
|
1341
|
+
membership_version=organization.membership_version+1),
|
|
1342
|
+
organization.membership_version)
|
|
1343
|
+
return HandlerOutput(resources=(ResourceRef(id=org),),data={'membership_policy':policy})
|
|
1344
|
+
|
|
1345
|
+
@op('group.join',obj({'group':IDENTIFIER},('group',)),signature=True)
|
|
1346
|
+
async def group_join(ctx,request,tx):
|
|
1347
|
+
org,_,organization,member,_=await group_context(ctx,request,tx)
|
|
1348
|
+
await app.authorizer.require_base(ctx.principal,operation_id(request),org,tx)
|
|
1349
|
+
require(organization.membership_policy in {'open','approval','invite'},'group_managed')
|
|
1350
|
+
require(member is None or member.status!='active','already_group_member')
|
|
1351
|
+
if organization.membership_policy=='invite':
|
|
1352
|
+
require(member is not None and member.status=='invited','group_invitation_required')
|
|
1353
|
+
status=('pending' if organization.membership_policy=='approval' and
|
|
1354
|
+
(member is None or member.status!='invited') else 'active')
|
|
1355
|
+
await set_member(tx,org,ctx.principal.subject,'member',status=status,
|
|
1356
|
+
joined_at=ctx.now if status=='active' else None,
|
|
1357
|
+
invited_by=member.invited_by if member else None)
|
|
1358
|
+
return HandlerOutput(resources=(ResourceRef(id=org),),data={'status':status})
|
|
1359
|
+
|
|
1360
|
+
@op('group.invite',obj({'group':IDENTIFIER,'subject':IDENTIFIER},('group','subject')),signature=True)
|
|
1361
|
+
async def group_invite(ctx,request,tx):
|
|
1362
|
+
org,_,_,_,role=await group_context(ctx,request,tx)
|
|
1363
|
+
await group_manager(ctx,request,tx,org,role)
|
|
1364
|
+
target=await resolve(tx,request.arguments['subject'])
|
|
1365
|
+
subject=await tx.subject(target)
|
|
1366
|
+
require(subject.kind in {'registered','custodial'} and not subject.local_only,'invalid_group_subject')
|
|
1367
|
+
previous=group_member(tx,org,target)
|
|
1368
|
+
require(previous is None or previous.status!='active','already_group_member')
|
|
1369
|
+
await set_member(tx,org,target,'member',status='invited',invited_by=ctx.principal.subject)
|
|
1370
|
+
return HandlerOutput(resources=(ResourceRef(id=org),),data={'subject_id':target,'status':'invited'})
|
|
1371
|
+
|
|
1372
|
+
@op('group.approve',obj({'group':IDENTIFIER,'subject':IDENTIFIER},('group','subject')),signature=True)
|
|
1373
|
+
async def group_approve(ctx,request,tx):
|
|
1374
|
+
org,_,_,_,role=await group_context(ctx,request,tx)
|
|
1375
|
+
await group_manager(ctx,request,tx,org,role)
|
|
1376
|
+
target=await resolve(tx,request.arguments['subject'])
|
|
1377
|
+
previous=group_member(tx,org,target)
|
|
1378
|
+
require(previous is not None and previous.status=='pending','group_request_not_pending')
|
|
1379
|
+
await set_member(tx,org,target,'member',joined_at=ctx.now,invited_by=ctx.principal.subject)
|
|
1380
|
+
return HandlerOutput(resources=(ResourceRef(id=org),),data={'subject_id':target,'status':'active'})
|
|
1381
|
+
|
|
1382
|
+
@op('group.reject',obj({'group':IDENTIFIER,'subject':IDENTIFIER},('group','subject')),signature=True)
|
|
1383
|
+
async def group_reject(ctx,request,tx):
|
|
1384
|
+
org,_,_,_,role=await group_context(ctx,request,tx)
|
|
1385
|
+
await group_manager(ctx,request,tx,org,role)
|
|
1386
|
+
target=await resolve(tx,request.arguments['subject'])
|
|
1387
|
+
previous=group_member(tx,org,target)
|
|
1388
|
+
require(previous is not None and previous.status=='pending','group_request_not_pending')
|
|
1389
|
+
await set_member(tx,org,target,'member',status='rejected')
|
|
1390
|
+
return HandlerOutput(resources=(ResourceRef(id=org),),data={'subject_id':target,'status':'rejected'})
|
|
1391
|
+
|
|
1392
|
+
@op('group.leave',obj({'group':IDENTIFIER},('group',)),signature=True)
|
|
1393
|
+
async def group_leave(ctx,request,tx):
|
|
1394
|
+
org,resource,_,member,_=await group_context(ctx,request,tx)
|
|
1395
|
+
await app.authorizer.require_base(ctx.principal,operation_id(request),org,tx)
|
|
1396
|
+
require(ctx.principal.subject!=resource.owner,'cannot_remove_group_owner')
|
|
1397
|
+
require(member is not None,'group_membership_not_found')
|
|
1398
|
+
await remove_group_member(tx,org,ctx.principal.subject)
|
|
1399
|
+
return HandlerOutput(resources=(ResourceRef(id=org),),data={'status':'left'})
|
|
1400
|
+
|
|
1401
|
+
@op('group.remove',obj({'group':IDENTIFIER,'subject':IDENTIFIER},('group','subject')),signature=True)
|
|
1402
|
+
async def group_remove(ctx,request,tx):
|
|
1403
|
+
org,resource,_,_,role=await group_context(ctx,request,tx)
|
|
1404
|
+
manager_is_owner=await group_manager(ctx,request,tx,org,role)
|
|
1405
|
+
target=await resolve(tx,request.arguments['subject'])
|
|
1406
|
+
require(target!=resource.owner,'cannot_remove_group_owner')
|
|
1407
|
+
member=group_member(tx,org,target)
|
|
1408
|
+
require(member is not None,'group_membership_not_found')
|
|
1409
|
+
if member.status=='active' and member.role in {'owner','maintainer','admin'}:
|
|
1410
|
+
require(manager_is_owner,'group_owner_required')
|
|
1411
|
+
await remove_group_member(tx,org,target)
|
|
1412
|
+
return HandlerOutput(resources=(ResourceRef(id=org),),data={'subject_id':target,'status':'removed'})
|
|
1413
|
+
|
|
1414
|
+
@op('group.role_set',obj({'group':IDENTIFIER,'subject':IDENTIFIER,
|
|
1415
|
+
'role':{'enum':['member','maintainer']}},('group','subject','role')),signature=True)
|
|
1416
|
+
async def group_role_set(ctx,request,tx):
|
|
1417
|
+
org,resource,_,_,role=await group_context(ctx,request,tx)
|
|
1418
|
+
await group_manager(ctx,request,tx,org,role,owner=True)
|
|
1419
|
+
target=await resolve(tx,request.arguments['subject'])
|
|
1420
|
+
require(target!=resource.owner,'cannot_demote_group_owner')
|
|
1421
|
+
member=group_member(tx,org,target)
|
|
1422
|
+
require(member is not None and member.status=='active','group_membership_not_active')
|
|
1423
|
+
await set_member(tx,org,target,request.arguments['role'],joined_at=member.joined_at,
|
|
1424
|
+
invited_by=member.invited_by)
|
|
1425
|
+
return HandlerOutput(resources=(ResourceRef(id=org),),data={'subject_id':target,'role':request.arguments['role']})
|
|
1426
|
+
|
|
1427
|
+
@op('group.owner_transfer',obj({'group':IDENTIFIER,'subject':IDENTIFIER},('group','subject')),signature=True)
|
|
1428
|
+
async def group_owner_transfer(ctx,request,tx):
|
|
1429
|
+
org,resource,_,current,role=await group_context(ctx,request,tx)
|
|
1430
|
+
await group_manager(ctx,request,tx,org,role,owner=True)
|
|
1431
|
+
target=await resolve(tx,request.arguments['subject'])
|
|
1432
|
+
require(target!=resource.owner,'already_group_owner')
|
|
1433
|
+
member=group_member(tx,org,target)
|
|
1434
|
+
require(member is not None and member.status=='active','group_membership_not_active')
|
|
1435
|
+
require(current is not None and current.status=='active','group_owner_missing')
|
|
1436
|
+
await set_member(tx,org,resource.owner,'maintainer',joined_at=current.joined_at,
|
|
1437
|
+
invited_by=current.invited_by)
|
|
1438
|
+
await set_member(tx,org,target,'owner',joined_at=member.joined_at,invited_by=member.invited_by)
|
|
1439
|
+
updated=replace(resource,owner=target,generation=resource.generation+1,
|
|
1440
|
+
modified_at=ctx.now,modified_by=ctx.principal.actor)
|
|
1441
|
+
await tx.replace(updated,resource.generation)
|
|
1442
|
+
return output_for(updated,owner=target)
|
|
1443
|
+
|
|
1444
|
+
@op('cert.request',obj({'requested_issuer':IDENTIFIER,'public_key':BYTES,'kind':{'enum':['identity','capability','ca']},
|
|
1445
|
+
'grants':GRANTS,'issuance':ISSUANCE,'requested_ttl_seconds':{'type':'integer','minimum':1},
|
|
1446
|
+
'delegation_depth':INTEGER,'possession_proof':SIGNATURE},
|
|
1447
|
+
('requested_issuer','public_key','kind','grants','requested_ttl_seconds','possession_proof')),signature=True)
|
|
1448
|
+
async def cert_request(ctx,request,tx):
|
|
1449
|
+
await app.authorizer.require_base(ctx.principal,operation_id(request),ctx.principal.subject,tx)
|
|
1450
|
+
a=request.arguments
|
|
1451
|
+
issuer=await resolve(tx,a['requested_issuer'])
|
|
1452
|
+
require((await tx.resource(issuer)).type=='user','invalid_issuer')
|
|
1453
|
+
public=unb64(a['public_key'],limit=32)
|
|
1454
|
+
grants=tuple(decode(CapabilityGrant,g) for g in a['grants'])
|
|
1455
|
+
for grant in grants:
|
|
1456
|
+
await app.certificates.validate_grant(grant,tx)
|
|
1457
|
+
issuance=decode(IssuancePolicy,a['issuance']) if a.get('issuance') is not None else None
|
|
1458
|
+
require((a['kind']=='ca')==(issuance is not None),'ca_issuance_required')
|
|
1459
|
+
if issuance:
|
|
1460
|
+
for grant in issuance.issue_grants:
|
|
1461
|
+
await app.certificates.validate_grant(grant,tx)
|
|
1462
|
+
csr=CertificateRequest(resource_id=new_id('csr'),applicant=ctx.principal.subject,subject_id=ctx.principal.subject,
|
|
1463
|
+
requested_issuer=issuer,public_key=public,kind=a['kind'],grants=grants,issuance=issuance,
|
|
1464
|
+
requested_ttl_seconds=a['requested_ttl_seconds'],target_service=app.settings.service_url,
|
|
1465
|
+
delegation_depth=a.get('delegation_depth',0),authority_sources=(),request_digest='',
|
|
1466
|
+
possession_proof=decode(Signature,a['possession_proof']))
|
|
1467
|
+
csr=replace(csr,request_digest=digest(csr_body(csr)))
|
|
1468
|
+
verify_csr(csr)
|
|
1469
|
+
from msg.storage.capacity import require_csr_capacity
|
|
1470
|
+
require_csr_capacity(tx)
|
|
1471
|
+
# A CSR-only key is a verifier, not an unrestricted login key.
|
|
1472
|
+
existing=tx.one('SELECT subject FROM credentials WHERE id=?',(key_id(public),))
|
|
1473
|
+
require(existing is None or existing[0]==ctx.principal.subject,'key_owner_mismatch')
|
|
1474
|
+
if existing is None:
|
|
1475
|
+
subject=await tx.subject(ctx.principal.subject)
|
|
1476
|
+
await tx.save_credential(Credential(id=key_id(public),subject_id=subject.resource_id,kind='signing_key',
|
|
1477
|
+
verifier=public,ceiling=(),not_before=ctx.now,expires_at=None,revoked_at=None),subject.auth_version)
|
|
1478
|
+
await tx.insert(Resource(id=csr.resource_id,type='csr',type_version=1,name=csr.resource_id,parent=CSR_SPACE,
|
|
1479
|
+
owner=ctx.principal.subject,group=PUBLIC_GROUP,mode=0o600,generation=0,revision=None,state='active',
|
|
1480
|
+
created_at=ctx.now,created_by=ctx.principal.actor,modified_at=ctx.now,modified_by=ctx.principal.actor))
|
|
1481
|
+
await tx.save_csr(csr)
|
|
1482
|
+
return HandlerOutput(resources=(ResourceRef(id=csr.resource_id),),data={'csr_id':csr.resource_id,
|
|
1483
|
+
'request_digest':csr.request_digest,'status':'pending'})
|
|
1484
|
+
|
|
1485
|
+
@op('cert.get',obj({'id':IDENTIFIER},('id',)),effect='read')
|
|
1486
|
+
async def cert_get(ctx,request,tx):
|
|
1487
|
+
rid=await resolve(tx,request.arguments['id'])
|
|
1488
|
+
await check_access(app,ctx,request,tx,rid,'read')
|
|
1489
|
+
r=await tx.resource(rid)
|
|
1490
|
+
if r.type=='csr':
|
|
1491
|
+
return HandlerOutput(data={'request':wire(await tx.csr(rid)),'state':wire(await tx.csr_state(rid))})
|
|
1492
|
+
require(r.type=='certificate','not_a_certificate')
|
|
1493
|
+
return HandlerOutput(data={'certificate':wire(await tx.certificate(rid)),'revoked':await tx.certificate_revoked(rid)})
|
|
1494
|
+
|
|
1495
|
+
@op('cert.cancel',obj({'id':IDENTIFIER},('id',)),signature=True)
|
|
1496
|
+
async def cert_cancel(ctx,request,tx):
|
|
1497
|
+
rid=await resolve(tx,request.arguments['id'])
|
|
1498
|
+
csr=await tx.csr(rid)
|
|
1499
|
+
require(csr.applicant==ctx.principal.subject,'csr_owner_required')
|
|
1500
|
+
await app.authorizer.require_base(ctx.principal,operation_id(request),rid,tx)
|
|
1501
|
+
state=await tx.csr_state(rid)
|
|
1502
|
+
await tx.transition_csr(replace(state,status='cancelled',generation=state.generation+1),state.generation)
|
|
1503
|
+
return HandlerOutput(resources=(ResourceRef(id=rid),),data={'status':'cancelled'})
|
|
1504
|
+
|
|
1505
|
+
@op('cert.publish',obj({'csr_id':IDENTIFIER,'certificate':{'type':'object'}},('csr_id','certificate')),signature=True)
|
|
1506
|
+
async def cert_publish(ctx,request,tx):
|
|
1507
|
+
csr=await tx.csr(request.arguments['csr_id'])
|
|
1508
|
+
state=await tx.csr_state(csr.resource_id)
|
|
1509
|
+
require(state.status=='pending','csr_not_pending')
|
|
1510
|
+
cert=decode(Certificate,request.arguments['certificate'])
|
|
1511
|
+
require(cert.issuer_id==ctx.principal.actor==ctx.principal.subject and cert.issuer_id!=ROOT_SUBJECT,'issuer_mismatch')
|
|
1512
|
+
await app.authorizer._ceiling(ctx.principal,operation_id(request),csr.resource_id,tx)
|
|
1513
|
+
require(await app.authorizer.has(ctx.principal,'cert.issue',operation_id(request),csr.resource_id,tx),'certificate_issue_required')
|
|
1514
|
+
await certificate_resource(tx,cert,ctx.now)
|
|
1515
|
+
await app.certificates.validate_publication(cert,csr,tx)
|
|
1516
|
+
await tx.register_certificate(cert,csr.resource_id,state.generation)
|
|
1517
|
+
credential=await tx.credential(cert.key_id)
|
|
1518
|
+
if not credential.ceiling:
|
|
1519
|
+
subject=await tx.subject(credential.subject_id)
|
|
1520
|
+
await tx.save_credential(replace(credential,ceiling=cert.grants),subject.auth_version)
|
|
1521
|
+
return HandlerOutput(resources=(ResourceRef(id=cert.resource_id),),data={'certificate_id':cert.resource_id,'status':'issued'})
|
|
1522
|
+
|
|
1523
|
+
@op('cert.revoke',obj({'id':IDENTIFIER,'reason':STRING},('id','reason')),signature=True)
|
|
1524
|
+
async def cert_revoke(ctx,request,tx):
|
|
1525
|
+
rid=await resolve(tx,request.arguments['id'])
|
|
1526
|
+
certificate=await tx.certificate(rid)
|
|
1527
|
+
require(certificate.subject_id!=ROOT_SUBJECT,'local_only')
|
|
1528
|
+
require(await app.authorizer.has(ctx.principal,'cert.revoke',operation_id(request),rid,tx),'certificate_revoke_required')
|
|
1529
|
+
event=Event(id=new_id('e'),type='cert.revoke',time=ctx.now,request_id=request.request_id,
|
|
1530
|
+
actor=ctx.principal.actor,subject=ctx.principal.subject,resources=(ResourceRef(id=rid),),
|
|
1531
|
+
data={'reason':request.arguments['reason']})
|
|
1532
|
+
audit=AuditEvent(event=event,authority=tuple(ResourceRef(id=c) for c in ctx.principal.certificates),
|
|
1533
|
+
before_digest=digest(certificate),after_digest=None,previous_digest=None,entry_digest='',result='revoked')
|
|
1534
|
+
await tx.revoke_certificate(rid,audit)
|
|
1535
|
+
return HandlerOutput(resources=(ResourceRef(id=rid),),data={'revoked':True})
|
|
1536
|
+
|
|
1537
|
+
@op('identity.delegate',obj({'grantee':IDENTIFIER,'key_id':IDENTIFIER,'grants':GRANTS,
|
|
1538
|
+
'ttl':{'type':'integer','minimum':1,'maximum':2592000},'depth':{'type':'integer','minimum':0,'maximum':8}},
|
|
1539
|
+
('grantee','key_id','grants','ttl')),signature=True)
|
|
1540
|
+
async def delegate(ctx,request,tx):
|
|
1541
|
+
await app.online_issuer(tx)
|
|
1542
|
+
a=request.arguments
|
|
1543
|
+
grantee=await resolve(tx,a['grantee'])
|
|
1544
|
+
key=await tx.credential(a['key_id'])
|
|
1545
|
+
require(key.subject_id==grantee and key.kind=='signing_key' and key.revoked_at is None,'invalid_delegate_key')
|
|
1546
|
+
grants=tuple(decode(CapabilityGrant,g) for g in a['grants'])
|
|
1547
|
+
parent_cert=None
|
|
1548
|
+
if ctx.principal.actor!=ctx.principal.subject:
|
|
1549
|
+
candidates=[await app.certificates.validate(c,tx) for c in ctx.principal.certificates]
|
|
1550
|
+
candidates=[c for c in candidates if c.kind=='delegation' and c.delegation_depth>0]
|
|
1551
|
+
require(bool(candidates),'redelegation_forbidden')
|
|
1552
|
+
parent_cert=candidates[0]
|
|
1553
|
+
require(a.get('depth',0)<parent_cert.delegation_depth,'delegation_depth_exceeded')
|
|
1554
|
+
for grant in grants:
|
|
1555
|
+
require(grant.capability in app.base_capability_names,'special_delegation_requires_ca')
|
|
1556
|
+
await app.certificates.validate_grant(grant,tx)
|
|
1557
|
+
resource=await tx.resource(grant.scope.resource_id)
|
|
1558
|
+
require(resource.owner==ctx.principal.subject,'delegation_owner_required')
|
|
1559
|
+
await app.authorizer._ceiling(ctx.principal,operation_id(request),resource.id,tx)
|
|
1560
|
+
if parent_cert:
|
|
1561
|
+
require(any([g.capability==grant.capability and grant.operations<=g.operations and
|
|
1562
|
+
await scope_subset(grant.scope,g.scope,tx) for g in parent_cert.grants]),'redelegation_scope')
|
|
1563
|
+
fact={'grantor':ctx.principal.subject,'grantee':grantee,'grants':wire(grants),
|
|
1564
|
+
'expires_at':wire(ctx.now+timedelta(seconds=a['ttl'])),
|
|
1565
|
+
'parent_certificate':parent_cert.resource_id if parent_cert else None}
|
|
1566
|
+
resource=await create_resource(app,ctx,request,tx,parent=ctx.principal.subject,type='delegation',
|
|
1567
|
+
name=new_id('delegation'),body=canonical(fact),media_type='application/json',mode=0o600)
|
|
1568
|
+
tx.set_setting('delegation:'+resource.id,fact)
|
|
1569
|
+
cert=await issue_online(app,tx,grantee,key.id,ctx,request,grants=grants,kind='delegation',
|
|
1570
|
+
sources=(ResourceRef(id=resource.id,revision=resource.revision),),depth=a.get('depth',0))
|
|
1571
|
+
return HandlerOutput(resources=(ResourceRef(id=resource.id,revision=resource.revision),),
|
|
1572
|
+
data={'certificate_id':cert.resource_id,'grantee':grantee})
|
|
1573
|
+
|
|
1574
|
+
@op('identity.delegation_revoke',obj({'id':IDENTIFIER},('id',)),signature=True)
|
|
1575
|
+
async def delegation_revoke(ctx,request,tx):
|
|
1576
|
+
rid=await resolve(tx,request.arguments['id'])
|
|
1577
|
+
resource=await tx.resource(rid)
|
|
1578
|
+
require(resource.type=='delegation' and resource.owner==ctx.principal.subject,'delegation_owner_required')
|
|
1579
|
+
await app.authorizer._ceiling(ctx.principal,operation_id(request),rid,tx)
|
|
1580
|
+
await tx.replace(replace(resource,state='archived',generation=resource.generation+1,
|
|
1581
|
+
modified_at=ctx.now,modified_by=ctx.principal.actor),resource.generation)
|
|
1582
|
+
return HandlerOutput(resources=(ResourceRef(id=rid),),data={'revoked':True})
|
|
1583
|
+
|
|
1584
|
+
@op('identity.email_set',obj({'address':STRING},('address',)),signature=True)
|
|
1585
|
+
async def email_set(ctx,request,tx):
|
|
1586
|
+
subject=await controlled_owner(app,ctx,request,tx)
|
|
1587
|
+
address=request.arguments['address']
|
|
1588
|
+
require(len(address)<=254 and re.fullmatch(r'[^\s@]+@[^\s@]+\.[^\s@]+',address) is not None and '\r' not in address and '\n' not in address,
|
|
1589
|
+
'invalid_email')
|
|
1590
|
+
row=tx.one('SELECT generation FROM emails WHERE subject=?',(subject.resource_id,))
|
|
1591
|
+
await tx.update_identity(EmailSettings(subject_id=subject.resource_id,address=address,verified_at=None),row[0] if row else -1)
|
|
1592
|
+
token=os.urandom(32)
|
|
1593
|
+
tx.execute('INSERT INTO email_challenges VALUES (?,?,?) ON CONFLICT(subject) DO UPDATE SET digest=excluded.digest,expires=excluded.expires',
|
|
1594
|
+
(subject.resource_id,digest(token),wire(ctx.now+timedelta(minutes=15))),write=True)
|
|
1595
|
+
if app.settings.server.mail is not None:
|
|
1596
|
+
from msg.plugins.communication import event_id
|
|
1597
|
+
job=EffectJob(id=new_id('job'),event_id=event_id(request,subject.resource_id),kind='mail',dedupe_key='email-verify:'+subject.resource_id+':'+request.request_id,
|
|
1598
|
+
principal=ctx.principal,operation=request.operation,arguments={'recipient':address,'recipient_subject':subject.resource_id,'verification':True,'subject':'Verify msg email',
|
|
1599
|
+
'text':'Verify with msg call identity.email_verify using JSON token: '+b64(token)},state='pending',attempts=0,next_attempt_at=ctx.now,lease_until=None)
|
|
1600
|
+
await tx.enqueue(job)
|
|
1601
|
+
return HandlerOutput(data={'verified':False,'delivery':'queued' if app.settings.server.mail else 'mail_disabled'})
|
|
1602
|
+
|
|
1603
|
+
@op('identity.email_verify',obj({'token':BYTES},('token',)),signature=True)
|
|
1604
|
+
async def email_verify(ctx,request,tx):
|
|
1605
|
+
subject=await controlled_owner(app,ctx,request,tx)
|
|
1606
|
+
row=tx.one('SELECT digest,expires FROM email_challenges WHERE subject=?',(subject.resource_id,))
|
|
1607
|
+
require(row is not None and parse_time(row[1])>ctx.now and
|
|
1608
|
+
hmac.compare_digest(row[0],digest(unb64(request.arguments['token']))),'invalid_email_challenge')
|
|
1609
|
+
email=tx.one('SELECT generation,body FROM emails WHERE subject=?',(subject.resource_id,))
|
|
1610
|
+
settings=decode(EmailSettings,loads(email[1]))
|
|
1611
|
+
await tx.update_identity(replace(settings,verified_at=ctx.now),email[0])
|
|
1612
|
+
tx.execute('DELETE FROM email_challenges WHERE subject=?',(subject.resource_id,),write=True)
|
|
1613
|
+
return HandlerOutput(data={'verified':True})
|
|
1614
|
+
|
|
1615
|
+
@op('identity.email_get',obj(),effect='read',signature=True)
|
|
1616
|
+
async def email_get(ctx,request,tx):
|
|
1617
|
+
subject=await controlled_owner(app,ctx,request,tx)
|
|
1618
|
+
row=tx.one('SELECT body FROM emails WHERE subject=?',(subject.resource_id,))
|
|
1619
|
+
return HandlerOutput(data={'email':loads(row[0]) if row else None})
|
|
1620
|
+
|
|
1621
|
+
@op('identity.email_notifications',obj({'events':{'type':'array','items':{'enum':['communication.send','discussion.reply','mention','cert.request','cert.issued']}}},('events',)),signature=True)
|
|
1622
|
+
async def email_notifications(ctx,request,tx):
|
|
1623
|
+
subject=await controlled_owner(app,ctx,request,tx)
|
|
1624
|
+
row=tx.one('SELECT generation,body FROM emails WHERE subject=?',(subject.resource_id,))
|
|
1625
|
+
require(row is not None,'email_not_set')
|
|
1626
|
+
settings=decode(EmailSettings,loads(row[1]))
|
|
1627
|
+
require(settings.verified_at is not None or not request.arguments['events'],'email_not_verified')
|
|
1628
|
+
await tx.update_identity(replace(settings,enabled_events=frozenset(request.arguments['events'])),row[0])
|
|
1629
|
+
return HandlerOutput(data={'events':list(request.arguments['events'])})
|
|
1630
|
+
|
|
1631
|
+
@op('identity.recover',obj({'subject':IDENTIFIER,'public_key':BYTES,'possession_proof':SIGNATURE},
|
|
1632
|
+
('subject','public_key','possession_proof')),signature=True)
|
|
1633
|
+
async def recover(ctx,request,tx):
|
|
1634
|
+
target=await resolve(tx,request.arguments['subject'])
|
|
1635
|
+
subject=await tx.subject(target)
|
|
1636
|
+
require(not subject.local_only and target!=ROOT_SUBJECT,'local_only')
|
|
1637
|
+
require(await app.authorizer.has(ctx.principal,'identity.recover',operation_id(request),target,tx),'identity_recovery_required')
|
|
1638
|
+
public=unb64(request.arguments['public_key'],limit=32)
|
|
1639
|
+
verify(public,canonical({'subject_id':target,'public_key':b64(public)}),
|
|
1640
|
+
decode(Signature,request.arguments['possession_proof']),purpose='recover')
|
|
1641
|
+
for row in tx.rows('SELECT body FROM credentials WHERE subject=?',(target,)):
|
|
1642
|
+
await tx.save_credential(replace(decode(Credential,loads(row[0])),revoked_at=ctx.now),subject.auth_version)
|
|
1643
|
+
credential=Credential(id=key_id(public),subject_id=target,kind='signing_key',verifier=public,
|
|
1644
|
+
ceiling=app.primary_ceiling(),not_before=ctx.now,expires_at=None,revoked_at=None)
|
|
1645
|
+
await tx.save_credential(credential,subject.auth_version)
|
|
1646
|
+
await tx.update_identity(replace(subject,auth_version=subject.auth_version+1),subject.auth_version)
|
|
1647
|
+
cert=await issue_online(app,tx,target,credential.id,ctx,request)
|
|
1648
|
+
return HandlerOutput(resources=(ResourceRef(id=target),),data={'key_id':credential.id,'certificate_id':cert.resource_id})
|
|
1649
|
+
|
|
1650
|
+
all_types=('topic','post','template','file','attachment','tool','user','organization','certificate','csr','delegation','repo','website','keystore','skill','legacy_directive','todo')
|
|
1651
|
+
types=[ResourceTypeSpec(name=name,version=1,container=name in {'topic','user','organization','repo','website'},
|
|
1652
|
+
content_schema=None,operations=frozenset(),relations=frozenset({'reply_to','thread_root','quote','repost','attachment','template'}),
|
|
1653
|
+
taggable=name in {'post','topic','repo'},purchasable=name=='website') for name in all_types]
|
|
1654
|
+
finish(types)
|