aics-conformance 0.1.0__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.
- aics_conformance/__init__.py +2 -0
- aics_conformance/__main__.py +2 -0
- aics_conformance/adapter.py +51 -0
- aics_conformance/card.py +95 -0
- aics_conformance/checks/__init__.py +1 -0
- aics_conformance/checks/access.py +38 -0
- aics_conformance/checks/gate.py +167 -0
- aics_conformance/checks/grants.py +29 -0
- aics_conformance/checks/http.py +24 -0
- aics_conformance/checks/identity.py +42 -0
- aics_conformance/checks/log.py +53 -0
- aics_conformance/checks/negative.py +56 -0
- aics_conformance/checks/rates.py +18 -0
- aics_conformance/cli.py +67 -0
- aics_conformance/client.py +123 -0
- aics_conformance/core.py +134 -0
- aics_conformance/dialogue.py +100 -0
- aics_conformance/fakes/__init__.py +3 -0
- aics_conformance/fakes/gate.py +117 -0
- aics_conformance/fakes/network.py +52 -0
- aics_conformance/fakes/server.py +273 -0
- aics_conformance/guarantees/__init__.py +1 -0
- aics_conformance/guarantees/g1.py +9 -0
- aics_conformance/guarantees/g10.py +6 -0
- aics_conformance/guarantees/g2.py +4 -0
- aics_conformance/guarantees/g3.py +17 -0
- aics_conformance/guarantees/g4.py +4 -0
- aics_conformance/guarantees/g5.py +33 -0
- aics_conformance/guarantees/g6.py +25 -0
- aics_conformance/guarantees/g7.py +8 -0
- aics_conformance/guarantees/g8.py +12 -0
- aics_conformance/guarantees/g9.py +11 -0
- aics_conformance/policy.py +80 -0
- aics_conformance/protocol.py +118 -0
- aics_conformance/schema.py +150 -0
- aics_conformance/verify.py +302 -0
- aics_conformance-0.1.0.dist-info/METADATA +67 -0
- aics_conformance-0.1.0.dist-info/RECORD +41 -0
- aics_conformance-0.1.0.dist-info/WHEEL +4 -0
- aics_conformance-0.1.0.dist-info/entry_points.txt +3 -0
- aics_conformance-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""The only deployment-specific wire mapping.
|
|
2
|
+
|
|
3
|
+
AICS-1 draft Default deployment wire
|
|
4
|
+
submit / entry / body /v2/entries, /v2/entries/{index}, /v2/bodies/{hash}
|
|
5
|
+
index / proofs /v2/tlog/index/{hash}, /v2/tlog/{inclusion,consistency}
|
|
6
|
+
name / inbox /v2/names/{name}, /v2/inbox/{kid}
|
|
7
|
+
gate / discovery /t, /llms.txt, /?format=json, /.well-known/aics-card.json
|
|
8
|
+
owner actions signed report/owner_action at /v2/entries (§5.3)
|
|
9
|
+
name lookup (unspecified) {indices, checkpoint_note, x25519?, key_leaf_index?}
|
|
10
|
+
inbox (unspecified) {indices, checkpoint_note}; one complete result
|
|
11
|
+
body status (unspecified) {status, tombstone_index?, reason_code?}
|
|
12
|
+
legacy surfaces (probes) /v2/agents/register, /v2/auth/token, /v2/keys/generate
|
|
13
|
+
|
|
14
|
+
Change paths, field aliases, owner extension paths and lookup pagination here.
|
|
15
|
+
Do not relax signature inputs, closed entry schemas, trust or proof verification.
|
|
16
|
+
The committed identity API predates the final envelope profile; profile assembly
|
|
17
|
+
uses its public signing digest/key/line functions without patching that package.
|
|
18
|
+
"""
|
|
19
|
+
from urllib.parse import quote
|
|
20
|
+
|
|
21
|
+
PATHS = dict(submit='/v2/entries', entry='/v2/entries/{index}', body='/v2/bodies/{hash}',
|
|
22
|
+
index='/v2/tlog/index/{hash}', inclusion='/v2/tlog/inclusion',
|
|
23
|
+
consistency='/v2/tlog/consistency', name='/v2/names/{name}',
|
|
24
|
+
inbox='/v2/inbox/{kid}', gate='/t', llms='/llms.txt', banner='/?format=json',
|
|
25
|
+
card='/.well-known/aics-card.json', notice='/notice', openapi='/openapi.json')
|
|
26
|
+
FIELD_ALIASES = {} # e.g. {'receipt_note': 'receipt'}; semantic -> wire
|
|
27
|
+
LEGACY_PROBES = {'bearer': ['/v2/auth/token', '/v2/agents/register'],
|
|
28
|
+
'keygen': ['/v2/keys/generate', '/v2/agents/register']}
|
|
29
|
+
OWNER_PATHS = ['/v2/entries'] # Add instance-specific quote/complete endpoints here.
|
|
30
|
+
VERBS = {'post':'post', 'poll':'poll', 'vote':'vote', 'grant':'grant', 'send':'dm',
|
|
31
|
+
'store':'capsule', 'claim':'name.claim', 'rotate':'key.rotate',
|
|
32
|
+
'revoke':'key.revoke', 'x25519.rotate':'x25519.rotate', 'claim-upgrade':'key.rotate'}
|
|
33
|
+
READ_VERBS = ('help', 'who', 'find', 'read', 'inbox', 'fetch', 'quit')
|
|
34
|
+
|
|
35
|
+
def path(operation, **values):
|
|
36
|
+
return PATHS[operation].format(**{k:quote(str(v), safe='') for k,v in values.items()})
|
|
37
|
+
|
|
38
|
+
def field(obj, name):
|
|
39
|
+
return obj[name] if name in obj else obj[FIELD_ALIASES.get(name,name)]
|
|
40
|
+
|
|
41
|
+
def lookup(obj):
|
|
42
|
+
return field(obj, 'indices'), field(obj, 'checkpoint_note')
|
|
43
|
+
|
|
44
|
+
def banner(obj):
|
|
45
|
+
return field(obj, 'banner')
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def normalize_wire(obj):
|
|
49
|
+
"""Rename convenience-wrapper fields, never bytes within signed artifacts."""
|
|
50
|
+
if not isinstance(obj,dict): return obj
|
|
51
|
+
return {next((semantic for semantic,wire in FIELD_ALIASES.items() if wire==k),k):v for k,v in obj.items()}
|
aics_conformance/card.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Pinned JWS card, logged activation and prior-policy checks (§14.5)."""
|
|
2
|
+
import re
|
|
3
|
+
from aics_identity import keys
|
|
4
|
+
from .protocol import J,H,U,DU,strict_json
|
|
5
|
+
from .schema import require, ENUMS, refusal_codes
|
|
6
|
+
FIELDS=set('spec version origin issued_at expires policy_parent_index log_vkey policy_kid owner_kid tile_prefixes api_base gate proxy witness_policy max_checkpoint_age mmd_seconds policy_notice_seconds limits retention schemes tiers reason_codes secret_patterns policies assurances spec_versions signatures'.split())
|
|
7
|
+
#: AICS-1 (0.2-draft) §13.1: the card version, and the C2SP versions it pins (tlog-checkpoint, signed-note,
|
|
8
|
+
#: tlog-cosignature and tlog-witness v1.1.0). Any other card version is refused, the 0.1-draft card included.
|
|
9
|
+
VERSION='0.2-draft'
|
|
10
|
+
SPEC_VERSIONS={'tlog-tiles':'1.0.0','tlog-checkpoint':'1.1.0','signed-note':'1.1.0','tlog-cosignature':'1.1.0','tlog-witness':'1.1.0','a2a-card':'1.0.0'}
|
|
11
|
+
|
|
12
|
+
#: AICS-1 §20.9: the members of an activated card's ``sup``.
|
|
13
|
+
SUP_FIELDS={'asset_hash','contributions','intent_seconds','claim_seconds','record_seconds','storage'}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def card_spec(profiles):
|
|
17
|
+
"""The ``spec`` of a card activating exactly ``profiles`` (AICS-1 §§13.1, 20.9): ``AICS-1``, ``AICS-1+SUP-1``."""
|
|
18
|
+
return 'AICS-1'+''.join('+'+p for p in profiles)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def verify_sup(c):
|
|
22
|
+
"""AICS-1 §20.9: the ``sup`` member of an AICS-1+SUP-1 card."""
|
|
23
|
+
from .verify import sup_asset_hash
|
|
24
|
+
s=c['sup']
|
|
25
|
+
require(isinstance(s,dict) and set(s)==SUP_FIELDS,'sup member schema (AICS-1 §20.9)')
|
|
26
|
+
require(s['asset_hash']==sup_asset_hash(c['origin']),'sup asset_hash is not the SUP asset of the card origin')
|
|
27
|
+
require(isinstance(s['contributions'],list),'sup contributions array')
|
|
28
|
+
networks=[]
|
|
29
|
+
for x in s['contributions']:
|
|
30
|
+
require(isinstance(x,dict) and set(x)=={'network','asset','decimals','address','units_per_sup'},'contribution network schema')
|
|
31
|
+
require(isinstance(x['network'],str) and re.fullmatch('[a-z0-9-]{1,32}',x['network']) is not None,'contribution network id')
|
|
32
|
+
require(all(isinstance(x[k],str) and x[k] for k in ('asset','address')),'contribution asset and address')
|
|
33
|
+
require(type(x['decimals']) is int and x['decimals']>=0 and type(x['units_per_sup']) is int and x['units_per_sup']>=2,'contribution units')
|
|
34
|
+
networks.append(x['network'])
|
|
35
|
+
require(len(set(networks))==len(networks),'each contribution network once')
|
|
36
|
+
require(all(type(s[k]) is int and s[k]>=0 for k in ('intent_seconds','claim_seconds','record_seconds')),'sup windows are uint')
|
|
37
|
+
require(0<s['intent_seconds']<=s['claim_seconds'] and s['record_seconds']<=31536000,'sup windows')
|
|
38
|
+
st=s['storage']
|
|
39
|
+
if st is not None:
|
|
40
|
+
require(isinstance(st,dict) and set(st)=={'free_quota_bytes','unit_bytes','unit_amount'},'sup storage schema')
|
|
41
|
+
require(type(st['free_quota_bytes']) is int and st['free_quota_bytes']>=65536,'free capsule quota below one maximal ciphertext')
|
|
42
|
+
units=(st['unit_bytes'],st['unit_amount'])
|
|
43
|
+
require((units[0] is None)==(units[1] is None) and all(v is None or type(v) is int and v>0 for v in units),'sup storage units')
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def verify_card(ctx, card=None):
|
|
47
|
+
c=card if card is not None else ctx.client.get('card',pages=True); ctx.evidence['policy_cards']=[U(J(c))]
|
|
48
|
+
profiles=tuple(getattr(ctx.policy,'profiles',()) or ()); spec=card_spec(profiles)
|
|
49
|
+
require(isinstance(c,dict) and c.get('spec')==spec,f"card profile {str(c.get('spec') if isinstance(c,dict) else None)[:40]!r} is not the pinned {spec!r} (a client reports an unsupported profile, AICS-1 §§19-20)")
|
|
50
|
+
require(set(c)==FIELDS|({'sup'} if 'SUP-1' in profiles else set()),'card exact schema')
|
|
51
|
+
require(c['version']==VERSION,f"card version must be {VERSION!r} (AICS-1 {VERSION} §13.1), not {str(c['version'])[:16]!r}")
|
|
52
|
+
require(c['spec_versions']==SPEC_VERSIONS,f'spec versions: AICS-1 {VERSION} §13.1 pins {SPEC_VERSIONS}')
|
|
53
|
+
for k in ('origin','log_vkey','policy_kid','owner_kid'): require(c[k]==ctx.policy.trust[k],'card replaced pinned '+k)
|
|
54
|
+
require(type(c['issued_at']) is int and type(c['expires']) is int and c['issued_at']<=ctx.now()+30 and c['issued_at']<c['expires'] and ctx.now()<c['expires'],'card time validity')
|
|
55
|
+
unsigned={k:v for k,v in c.items() if k!='signatures'}; require(bool(c['signatures']),'unsigned card')
|
|
56
|
+
for sig in c['signatures']:
|
|
57
|
+
require(set(sig)=={'protected','signature'},'card signature shape'); protected=strict_json(DU(sig['protected']))
|
|
58
|
+
require(J(protected)==DU(sig['protected']) and protected==dict(alg='EdDSA',kid=ctx.policy.trust['policy_kid'],typ='JOSE'),'card protected header')
|
|
59
|
+
keys.parse_did_key(ctx.policy.trust['policy_kid']).verify(DU(sig['signature']),(sig['protected']+'.'+U(J(unsigned))).encode())
|
|
60
|
+
hist,h=ctx.scan(); events=[(i,e) for i,e in enumerate(hist.entries) if e['type']=='policy']; matching=[(i,e) for i,e in events if e['payload']['document_hash']==H(J(c))]
|
|
61
|
+
require(len(matching)==1,'card lacks unique included policy event'); i,e=matching[0]; q=e['payload']
|
|
62
|
+
require(q['kind']=='card' and q['prev_policy_index']==c['policy_parent_index'] and q['size']==len(J(c)),'card policy linkage')
|
|
63
|
+
require(h.cp.size>max(i,q['effective_after']) and ctx.now()>=q['not_before'],'card not active')
|
|
64
|
+
cards=[(j,x) for j,x in events if x['payload']['kind']=='card' and x['payload']['effective_after']<h.cp.size and x['payload']['not_before']<=ctx.now()]
|
|
65
|
+
require(cards[-1][0]==i,'superseded card')
|
|
66
|
+
if ctx.policy.saved_card is None: require(H(J(c))==ctx.policy.trust['bootstrap_card_hash'],'initial card differs from pinned bootstrap hash; supply saved card')
|
|
67
|
+
elif H(J(ctx.policy.saved_card))!=H(J(c)):
|
|
68
|
+
old=ctx.policy.saved_card; require(q['not_before']>=e['ts']+old['policy_notice_seconds'],'card changed without prior notice')
|
|
69
|
+
require(any(j<i and x['payload']['document_hash']==H(J(old)) for j,x in events),'saved card missing from authenticated history')
|
|
70
|
+
require(c['mmd_seconds']==60 and c['max_checkpoint_age']>=60,'card timing profile')
|
|
71
|
+
wp=c['witness_policy']; require(set(wp)=={'quorum','witnesses'},'witness policy shape')
|
|
72
|
+
from aics_tlog import parse_vkey
|
|
73
|
+
wkeys=[parse_vkey(w['vkey']) for w in wp['witnesses']]
|
|
74
|
+
require(ctx.policy.quorum<=wp['quorum']<=len(wkeys) and len({w.public_key for w in wkeys})==len(wkeys),'witness uniqueness/quorum')
|
|
75
|
+
require(set(ctx.policy.witness_vkeys).issubset({w['vkey'] for w in wp['witnesses']}),'candidate removes pinned witnesses')
|
|
76
|
+
limits=c['limits']; expected=dict(public_body_bytes=32768,ciphertext_bytes=65536,envelope_window_seconds=300,future_skew_seconds=30,session_idle_seconds=900)
|
|
77
|
+
require(set(limits)==set(expected)|{'writes','registrations','preidentity','flood_suspension_seconds'},'limit schema')
|
|
78
|
+
for k,v in expected.items(): require(limits[k]==v,'incorrect '+k)
|
|
79
|
+
for k in ('writes','registrations','preidentity'): require(set(limits[k])=={'count','interval_seconds'} and all(type(x) is int and x>0 for x in limits[k].values()),'unbounded rate limits')
|
|
80
|
+
require(set(c['retention'])=={'dm_seconds','capsule_max_seconds','ciphertext_budget_bytes','eviction'} and c['retention']['eviction']=='oldest-ts-index','retention schema')
|
|
81
|
+
require(all(type(c['retention'][k]) is int and c['retention'][k]>0 for k in ('dm_seconds','capsule_max_seconds','ciphertext_budget_bytes')),'retention bounds')
|
|
82
|
+
patterns=c['secret_patterns']
|
|
83
|
+
require(isinstance(patterns,list) and all(isinstance(x,dict) and set(x)=={'id','pattern_hash'} and isinstance(x['id'],str) and re.fullmatch('[a-z0-9_]{1,32}',x['id']) and isinstance(x['pattern_hash'],str) and re.fullmatch('[0-9a-f]{64}',x['pattern_hash']) for x in patterns) and len({x['id'] for x in patterns})==len(patterns),'secret pattern schema')
|
|
84
|
+
require(set(c['reason_codes'])=={'refusal','tombstone','suspension','freeze','revocation','outcome'},'reason registries')
|
|
85
|
+
# §13.1: the §17.2 registries plus exactly the card's own secret_format:<id> instances (a closed expansion, §17.2)
|
|
86
|
+
active={'secret_format:'+x['id'] for x in patterns}
|
|
87
|
+
for k,vals in c['reason_codes'].items(): require(isinstance(vals,list) and set(vals)==(refusal_codes(profiles)|active if k=='refusal' else ENUMS[k]) and len(vals)==len(set(vals)),'reason registry differs')
|
|
88
|
+
if 'SUP-1' in profiles: verify_sup(c)
|
|
89
|
+
require(set(c['tiers'])=={'verified','third-party-generated','unverified'} and 'x25519-xsalsa20-poly1305' in c['schemes'],'required tier/scheme')
|
|
90
|
+
require(set(a['id'] for a in c['assurances'])=={f'G{i}' for i in range(1,11)}|{f'N{i}' for i in range(1,6)} and len(c['assurances'])==15,'assurance registry')
|
|
91
|
+
for a in c['assurances']:
|
|
92
|
+
require(set(a)=={'id','status','claim','assumptions','evidence'} and a['status'] in ('proven','promised','not-offered'),'assurance shape/status')
|
|
93
|
+
if a['id'].startswith('N'): require(a['status']=='not-offered','non-guarantee overstated')
|
|
94
|
+
require(bool(c['tile_prefixes']) and all(x.startswith('https://') for x in c['tile_prefixes']),'tile transport')
|
|
95
|
+
return c,hist,h
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from . import gate, log, identity, negative, access, rates, grants, http
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from ..core import check
|
|
2
|
+
from ..protocol import J,H
|
|
3
|
+
from ..schema import require
|
|
4
|
+
from .. import adapter
|
|
5
|
+
from .negative import post_attempt
|
|
6
|
+
|
|
7
|
+
@check('access.owner','Owner quote/complete endpoints reject non-owner signatures','AICS-1 §§3.1,5.3,12.1','owner')
|
|
8
|
+
def owner(ctx):
|
|
9
|
+
a=ctx.new_agent()
|
|
10
|
+
for action in ('quote','complete'):
|
|
11
|
+
q=dict(kind='owner_action',action=action,request_hash=H(b'request'),result_hash=None if action=='quote' else H(b'result'),asset_hash=H(b'asset'),amount=1,quote_index=None if action=='quote' else 0)
|
|
12
|
+
for route in adapter.OWNER_PATHS:
|
|
13
|
+
e=ctx.prepare(a,'report',q); r=ctx.client.submit(e,a.key,route=route)
|
|
14
|
+
require(400<=r.status<500,'non-owner action accepted')
|
|
15
|
+
# Profile's native endpoint must supply refusal evidence; extensions too.
|
|
16
|
+
obj=r.json(); reason=obj['entry']['payload']['reason_code']; ctx.refused(r,e,reason)
|
|
17
|
+
|
|
18
|
+
@check('access.bearer','No bearer authentication or token issuance surface','DECISIONS 1; AICS-1 §§3,12.1','bearer')
|
|
19
|
+
def bearer(ctx):
|
|
20
|
+
a=ctx.new_agent(); e,b=post_attempt(ctx,a)
|
|
21
|
+
r=ctx.client.submit(e,a.key,b,headers={'Authorization':'Bearer aics_live_conformance','Content-Type':'application/json'})
|
|
22
|
+
require(400<=r.status<500,'bearer-only write accepted')
|
|
23
|
+
for path in adapter.LEGACY_PROBES['bearer']:
|
|
24
|
+
r=ctx.client.request('POST',path,J({'name':'conformance','public_key':a.ed}),{'Content-Type':'application/json'})
|
|
25
|
+
require(r.status in (400,401,403,404,405,410,422),'legacy token route active')
|
|
26
|
+
require(not any(k in r.text().lower() for k in ('access_token','aics_live_','bearer_token','refresh_token')),'token returned')
|
|
27
|
+
schema=ctx.client.request('GET',adapter.path('openapi'))
|
|
28
|
+
if schema.status==200:
|
|
29
|
+
require('"bearer"' not in schema.text().lower(),'OpenAPI advertises bearer authentication')
|
|
30
|
+
return {'scope':'Configured native and legacy routes plus published OpenAPI; arbitrary undisclosed paths cannot be disproved by black-box probing.'}
|
|
31
|
+
|
|
32
|
+
@check('access.keygen','No server-generated key path or private key response','DECISIONS 1; AICS-1 §3.1','keygen')
|
|
33
|
+
def keygen(ctx):
|
|
34
|
+
for path in adapter.LEGACY_PROBES['keygen']:
|
|
35
|
+
r=ctx.client.request('POST',path,J({'name':'conformance-keygen-probe'}),{'Content-Type':'application/json'})
|
|
36
|
+
require(r.status in (400,401,403,404,405,410,422),'registration/keygen without public key accepted')
|
|
37
|
+
require(not any(k in r.text().lower() for k in ('private_seed','private_key','secret_key','seed_hex')),'server returned private key material')
|
|
38
|
+
return {'scope':'Configured key-generation and registration routes; no black-box claim about unadvertised paths.'}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from cryptography.hazmat.primitives import serialization
|
|
3
|
+
from aics_identity import linesig, sshsig
|
|
4
|
+
from .. import dialogue as d, adapter
|
|
5
|
+
from ..core import check
|
|
6
|
+
from ..protocol import Agent, J, H, U, B, DU, strict_json, command, sign_text
|
|
7
|
+
from ..schema import require
|
|
8
|
+
from .. import verify
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def reply(text):
|
|
12
|
+
require(text.endswith('\n>\n') or text=='>\n','reply missing terminal prompt')
|
|
13
|
+
lines=text.splitlines()[:-1]
|
|
14
|
+
require(bool(lines),'empty reply')
|
|
15
|
+
require(re.match(r'^(ok(?: |$)|err [a-z0-9_:]+ .+|retry .+|\? .+)',lines[0]) is not None,'reply prefix')
|
|
16
|
+
for line in lines:
|
|
17
|
+
if line.split(' ',1)[0] in ('entry','receipt','envelope','body','checkpoint'): DU(line.split(' ',1)[1])
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def artifacts(text):
|
|
21
|
+
found={}
|
|
22
|
+
for line in text.splitlines():
|
|
23
|
+
if line.startswith('ok envelope '): found['envelope']=DU(line[12:])
|
|
24
|
+
elif line.split(' ',1)[0] in ('entry','receipt','envelope'):
|
|
25
|
+
k,v=line.split(' ',1); found[k]=DU(v)
|
|
26
|
+
return found
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def onboard(ctx,branch='openssl',*,get=False,leeway=False):
|
|
30
|
+
g=ctx.client.gate(); first=g.exchange(get=get); require(d.TOOLS in first,'exact tools prompt')
|
|
31
|
+
a=Agent()
|
|
32
|
+
text=g.exchange('tools '+branch+(' python3' if branch=='openssl' else ''),get=get)
|
|
33
|
+
require((d.SSH_PUB if branch=='ssh-keygen' else d.NONE if branch=='none' else d.PUB) in text,'exact branch prompt')
|
|
34
|
+
if branch!='none':
|
|
35
|
+
expected={'openssl':d.OPENSSL,'ssh-keygen':d.SSH,'python3':d.PYTHON}[branch]
|
|
36
|
+
require(all(line in text for line in expected),'exact local key commands')
|
|
37
|
+
for other,cmds in {'openssl':d.OPENSSL,'ssh-keygen':d.SSH,'python3':d.PYTHON}.items():
|
|
38
|
+
if other!=branch: require(cmds[0] not in text,'server printed more than selected branch')
|
|
39
|
+
# AICS-1 §11.4 item 2: the openssl reply also prints the recipe for later raw line signing, session id filled in
|
|
40
|
+
if branch=='openssl': require(d.openssl_sign(g.session) in text,'exact OpenSSL line-signing instructions')
|
|
41
|
+
pub=a.key.public_key().public_bytes(serialization.Encoding.OpenSSH,serialization.PublicFormat.OpenSSH).decode()+' discarded-comment' if branch=='ssh-keygen' else B(a.key.public_key().public_bytes_raw())
|
|
42
|
+
steps=[('pub '+pub,d.ENC),('enc '+B(DU(a.x)),d.RECOVERY),('recovery none',d.NAME),('name '+a.name,d.GRANT)]
|
|
43
|
+
for line,prompt in steps:
|
|
44
|
+
if leeway and line.startswith(('enc ','pub ')): line=line.split()[0].upper()+" '"+line.split()[1].rstrip('=')+"'"
|
|
45
|
+
if leeway: line=' \t'+line+' \t'
|
|
46
|
+
text=g.exchange(line,get=get); require(prompt in text,'exact onboarding prompt: '+prompt)
|
|
47
|
+
text=g.exchange('closed',get=get); data=artifacts(text); unsigned=strict_json(data['envelope'])
|
|
48
|
+
p=unsigned['protected']; q=unsigned['payload']
|
|
49
|
+
require(q['ed25519']==a.ed and q['x25519']==a.x and q['name_norm']==a.name and q['initial_grant'] is None,'proposal changed keys/name/consent')
|
|
50
|
+
require(q['tier']==('third-party-generated' if branch=='none' else 'verified'),'wrong tier')
|
|
51
|
+
require(p['origin']==ctx.policy.trust['origin'] and p['kid']==a.kid and p['seq']==0 and p['prev'] is None,'claim proposal binding')
|
|
52
|
+
require(p['created']<=ctx.now()+30 and p['created']>=ctx.now()-300 and p['expires']<=p['created']+300 and p['expires']>ctx.now(),'claim proposal times')
|
|
53
|
+
require(unsigned['auth']==dict(scheme='text-sshsig' if branch=='ssh-keygen' else 'text-ed25519',session=g.session),'session signature scheme')
|
|
54
|
+
c=command(unsigned); require('? Sign this exactly: '+c in text,'exact claim commitment prompt')
|
|
55
|
+
if branch=='ssh-keygen': require(d.SSH_SIGN in text,'exact SSHSIG line-signing instructions')
|
|
56
|
+
reply(g.exchange(c,get=get))
|
|
57
|
+
sig=linesig.sign_line(g.session,c,a.key,form='sshsig' if branch=='ssh-keygen' else 'raw')
|
|
58
|
+
wire='sig-begin\n'+sig.rstrip('\n')+'\nsig-end' if branch=='ssh-keygen' else ('SiG: "'+sig.rstrip('=')+'"' if leeway else 'sig '+sig)
|
|
59
|
+
text=g.exchange(wire,get=get); reply(text); data=artifacts(text)
|
|
60
|
+
entry=strict_json(data['entry']); require(entry['payload']['envelope']['payload']==q,'claim differs from proposal')
|
|
61
|
+
verify.verify_signature(entry['payload']['envelope']); verify.receipt(data['receipt'],entry,ctx.policy)
|
|
62
|
+
i=ctx.follow(entry,data['receipt']); a.previous=entry['payload']['envelope']; a.claim_index=i; a.event_index=i
|
|
63
|
+
require(f'ok name {a.name} bound to key {a.kid} at leaf {i}; verify at /tiles' in text,'exact completion reply')
|
|
64
|
+
require('help' in text,'completion help summary')
|
|
65
|
+
return g,a
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@check('gate.banner','Banner identical across HTTPS, pages and TCP','AICS-1 §11.1; BANNER','banner')
|
|
69
|
+
def banner(ctx):
|
|
70
|
+
expected=d.BANNER
|
|
71
|
+
# /llms.txt begins with the banner (BANNER: served identically there) and may go on with its own text, as
|
|
72
|
+
# wellknown/llms.txt.header is the banner a deployment serves before /llms.txt's machine section
|
|
73
|
+
llms=ctx.client.request('GET',adapter.path('llms'),pages=True)
|
|
74
|
+
require(llms.status==200 and llms.text().startswith(expected),'published banner bytes')
|
|
75
|
+
require(adapter.banner(ctx.client.get('banner',pages=True))==expected,'JSON banner differs')
|
|
76
|
+
text=ctx.client.gate().exchange(); require(expected in text and text.index(expected)==0,'HTTPS banner differs')
|
|
77
|
+
g=ctx.tcp()
|
|
78
|
+
try: require(g.exchange().startswith(expected),'TCP banner differs')
|
|
79
|
+
finally: g.close()
|
|
80
|
+
|
|
81
|
+
for branch in ('openssl','ssh-keygen','python3','none'):
|
|
82
|
+
def fn(ctx,branch=branch): onboard(ctx,branch)
|
|
83
|
+
check('gate.onboarding.'+branch,'Exact onboarding: '+branch,'AICS-1 §11.4; PROTOCOL-TEXT §3','onboard_'+branch)(fn)
|
|
84
|
+
|
|
85
|
+
@check('gate.get','Fetch-only onboarding and read binding','AICS-1 §§11.1,11.4','onboard_openssl')
|
|
86
|
+
def get(ctx): onboard(ctx,get=True)
|
|
87
|
+
|
|
88
|
+
@check('gate.leeway','Whitespace, case, quotes, padding and sig: leeway','AICS-1 §11.2','leeway')
|
|
89
|
+
def leeway(ctx):
|
|
90
|
+
g,_=onboard(ctx,leeway=True); reply(g.exchange('\n \t\n HeLp \t'))
|
|
91
|
+
|
|
92
|
+
@check('gate.malformed','Exact retry, third malformed line closes, valid line resets','AICS-1 §11.2','malformed_rule')
|
|
93
|
+
def malformed(ctx):
|
|
94
|
+
g=ctx.client.gate(); g.exchange()
|
|
95
|
+
for i in range(2): require(g.exchange('invalid!')=='retry tools <space-separated list> or tools none\n>\n','exact retry')
|
|
96
|
+
reply(g.exchange('help'))
|
|
97
|
+
for i in range(2): require(g.exchange('invalid!').startswith('retry '),'valid line did not reset')
|
|
98
|
+
require(g.exchange('invalid!')=='err malformed help\n>\n','third malformed line termination')
|
|
99
|
+
require(not g.exchange('tools openssl').startswith('openssl'),'terminated session resumed')
|
|
100
|
+
|
|
101
|
+
@check('gate.get-write','GET gate refuses content writes and secrets','AICS-1 §11.1','get_write')
|
|
102
|
+
def get_write(ctx):
|
|
103
|
+
g,_=onboard(ctx)
|
|
104
|
+
for line in ('post e30','body YQ==','secret synthetic-not-a-real-credential','claim-upgrade name abcd'):
|
|
105
|
+
text=g.exchange(line,get=True); reply(text); require(text.startswith('err '),'GET performed a prohibited operation')
|
|
106
|
+
|
|
107
|
+
@check('gate.plain-fallback','Plain TCP never solicits a secret','AICS-1 §11.4(5)','plain_secret')
|
|
108
|
+
def plain_fallback(ctx):
|
|
109
|
+
g=ctx.tcp(plain=True)
|
|
110
|
+
try:
|
|
111
|
+
g.exchange(); g.exchange('tools none'); text=g.exchange('fallback')
|
|
112
|
+
require(text=='err tls_required use 7700 or HTTPS\n>\n','plaintext fallback solicited secret')
|
|
113
|
+
finally: g.close()
|
|
114
|
+
|
|
115
|
+
for verb in adapter.READ_VERBS:
|
|
116
|
+
def fn(ctx,verb=verb):
|
|
117
|
+
g,a=onboard(ctx); args={'find':a.name,'read':str(a.claim_index),'fetch':str(a.claim_index),'inbox':a.kid+' 0'}.get(verb,'')
|
|
118
|
+
text=g.exchange(verb+(' '+args if args else '')); reply(text); require(text.startswith('ok'),'read verb refused')
|
|
119
|
+
if verb in ('read','fetch'): require('entry' in artifacts(text),'read missing entry evidence')
|
|
120
|
+
check('gate.verb.'+verb,'Reply shape: '+verb,'AICS-1 §11.3','reply_'+verb)(fn)
|
|
121
|
+
|
|
122
|
+
def write_verb(ctx,verb):
|
|
123
|
+
from ..protocol import seal,rotation
|
|
124
|
+
import secrets
|
|
125
|
+
g,a=onboard(ctx)
|
|
126
|
+
if verb in ('claim','claim-upgrade'):
|
|
127
|
+
# Claim success is covered by the complete ceremony above. Upgrade without
|
|
128
|
+
# a fallback session must still return a framed refusal/retry, never success.
|
|
129
|
+
text=g.exchange(verb+' '+a.name+' '+'0'*64); reply(text)
|
|
130
|
+
require(not text.startswith('ok'),'unprepared claim/upgrade accepted')
|
|
131
|
+
return
|
|
132
|
+
body=None; nonce=U(secrets.token_bytes(16))
|
|
133
|
+
if verb in ('post','poll'):
|
|
134
|
+
obj=dict(channel='conformance',title=None if verb=='post' else 'synthetic poll',text='gate conformance body')
|
|
135
|
+
if verb=='poll': obj['options']=['a','b']
|
|
136
|
+
body=J(obj); q=dict(channel_sha256=H(b'conformance'),body_hash=H(body),size=len(body),title_sha256=None if verb=='post' else H(obj['title'].encode()))
|
|
137
|
+
if verb=='post': q['parent_index']=None
|
|
138
|
+
else: q.update(options_sha256=[H(b'a'),H(b'b')],closes=int(ctx.now())+600,min_key_age_seconds=0,pow_bits=0)
|
|
139
|
+
elif verb=='vote':
|
|
140
|
+
b=J(dict(channel='conformance',title='vote fixture',text='synthetic',options=['a','b']))
|
|
141
|
+
pq=dict(channel_sha256=H(b'conformance'),body_hash=H(b),size=len(b),title_sha256=H(b'vote fixture'),options_sha256=[H(b'a'),H(b'b')],closes=int(ctx.now())+600,min_key_age_seconds=0,pow_bits=0)
|
|
142
|
+
entry,note=ctx.submit(a,'poll',pq,b); pi=ctx.follow(entry,note)
|
|
143
|
+
q=dict(poll_index=pi,option=0,pow_nonce=None,reason_sha256=None,body_hash=None,size=0)
|
|
144
|
+
elif verb=='grant': q=dict(owner_kid=a.kid,sender_kid='*',max=1,expires=int(ctx.now())+600,prev_grant_index=None)
|
|
145
|
+
elif verb in ('send','store'):
|
|
146
|
+
recipient=ctx.new_agent() if verb=='send' else a
|
|
147
|
+
if verb=='send':
|
|
148
|
+
entry,note=ctx.submit(recipient,'grant',dict(owner_kid=recipient.kid,sender_kid=a.kid,max=1,expires=int(ctx.now())+600,prev_grant_index=None)); gi=ctx.follow(entry,note)
|
|
149
|
+
body=seal(b'gate private synthetic body',DU(recipient.x),a.kid,recipient.kid,nonce)
|
|
150
|
+
q=dict(to_key_leaf_index=recipient.claim_index,encryption='x25519-xsalsa20-poly1305',eph_pk=U(body[:32]),ct_hash=H(body),size=len(body))
|
|
151
|
+
if verb=='send': q.update(to_kid=recipient.kid,seq=a.previous['protected']['seq']+1,prev=H(J(a.previous)),grant_index=gi)
|
|
152
|
+
# §5.3: a capsule expires at least 90 days (7776000 s) after its ts; ten minutes of slack for the server's ts
|
|
153
|
+
else: q.update(owner_kid=a.kid,key_hash=H(b'opaque-test-slot'),expires=int(ctx.now())+7776000+600,prev_capsule_index=None)
|
|
154
|
+
elif verb=='rotate': q=rotation(a,Agent(name=a.name),ctx.policy.trust['origin'])
|
|
155
|
+
elif verb=='revoke': q=dict(name_norm=a.name,prev_event_index=a.event_index,reason_code='retired')
|
|
156
|
+
elif verb=='x25519.rotate': q=dict(name_norm=a.name,prev_event_index=a.event_index,new_x25519=Agent().x)
|
|
157
|
+
e=ctx.prepare(a,adapter.VERBS[verb],q,nonce=nonce); e.pop('sig'); e['auth']=dict(scheme='text-ed25519',session=g.session)
|
|
158
|
+
if body is not None: reply(g.exchange('body '+U(body)))
|
|
159
|
+
reply(g.exchange(command(e))); signed=sign_text(e,a.key)
|
|
160
|
+
text=g.exchange('sig '+B(DU(signed['sig']))); reply(text); require(text.startswith('ok'),'valid gate write refused')
|
|
161
|
+
data=artifacts(text); entry=strict_json(data['entry']); require(entry['payload']['envelope']==signed,'gate changed signed write')
|
|
162
|
+
i=ctx.follow(entry,data['receipt']); ctx.scan()
|
|
163
|
+
return dict(index=i,receipt_verified=True)
|
|
164
|
+
|
|
165
|
+
for verb in adapter.VERBS:
|
|
166
|
+
def fn(ctx,verb=verb): return write_verb(ctx,verb)
|
|
167
|
+
check('gate.verb.'+verb,'Reply and evidence: '+verb,'AICS-1 §11.3','reply_'+verb)(fn)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Consent reconstruction includes wildcard totals and exact revocation precedence."""
|
|
2
|
+
import secrets
|
|
3
|
+
from ..core import check
|
|
4
|
+
from ..protocol import U,DU,J,H,seal
|
|
5
|
+
from ..schema import require
|
|
6
|
+
|
|
7
|
+
def grant(ctx,recipient,sender,max,previous=None):
|
|
8
|
+
e,n=ctx.submit(recipient,'grant',dict(owner_kid=recipient.kid,sender_kid=sender,max=max,expires=int(ctx.now())+600,prev_grant_index=previous))
|
|
9
|
+
return ctx.follow(e,n)
|
|
10
|
+
|
|
11
|
+
def attempt(ctx,sender,recipient,gi):
|
|
12
|
+
nonce=U(secrets.token_bytes(16)); body=seal(b'grant probe',DU(recipient.x),sender.kid,recipient.kid,nonce)
|
|
13
|
+
q=dict(to_kid=recipient.kid,to_key_leaf_index=recipient.claim_index,encryption='x25519-xsalsa20-poly1305',eph_pk=U(body[:32]),ct_hash=H(body),size=len(body),seq=sender.previous['protected']['seq']+1,prev=H(J(sender.previous)),grant_index=gi)
|
|
14
|
+
e=ctx.prepare(sender,'dm',q,nonce=nonce)
|
|
15
|
+
return e,ctx.client.submit(e,sender.key,body)
|
|
16
|
+
|
|
17
|
+
@check('grants.wildcard','Wildcard allowance is total across all senders','AICS-1 §9','wildcard_capacity')
|
|
18
|
+
def wildcard(ctx):
|
|
19
|
+
recipient=ctx.new_agent(); first=ctx.new_agent(); second=ctx.new_agent(); gi=grant(ctx,recipient,'*',1)
|
|
20
|
+
e,r=attempt(ctx,first,recipient,gi); entry,note=ctx.accepted(r,first,e); ctx.follow(entry,note)
|
|
21
|
+
e,r=attempt(ctx,second,recipient,gi); ctx.refused(r,e,'grant_exhausted')
|
|
22
|
+
|
|
23
|
+
@check('grants.exact-revocation','Exact revocation never falls back to wildcard','AICS-1 §9','exact_revocation')
|
|
24
|
+
def revocation(ctx):
|
|
25
|
+
recipient=ctx.new_agent(); sender=ctx.new_agent(); grant(ctx,recipient,'*',4); gi=grant(ctx,recipient,sender.kid,0)
|
|
26
|
+
e,r=attempt(ctx,sender,recipient,gi)
|
|
27
|
+
require(400<=r.status<500,'exact revocation ignored')
|
|
28
|
+
reason=r.json()['entry']['payload']['reason_code']; require(reason in ('no_consent','grant_exhausted'),'wrong consent refusal')
|
|
29
|
+
ctx.refused(r,e,reason)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""RFC 9421 and envelope metadata must describe the same logical attempt."""
|
|
2
|
+
import secrets
|
|
3
|
+
from aics_identity import http_signatures
|
|
4
|
+
from ..core import check
|
|
5
|
+
from ..protocol import J,U
|
|
6
|
+
from ..schema import require
|
|
7
|
+
from .. import adapter
|
|
8
|
+
from .negative import post_attempt
|
|
9
|
+
|
|
10
|
+
@check('http.binding','Native HTTP signatures bind the exact envelope metadata','AICS-1 §12.1','http_binding')
|
|
11
|
+
def binding(ctx):
|
|
12
|
+
a=ctx.new_agent()
|
|
13
|
+
for changed in ('nonce','created','expires','label'):
|
|
14
|
+
e,b=post_attempt(ctx,a); p=e['protected']; raw=J(dict(envelope=e,body=U(b)))
|
|
15
|
+
args=dict(key=a.key,keyid=p['kid'],created=p['created'],expires=p['expires'],nonce=p['nonce'],label='aics',tag='aics',components=http_signatures.AICS_COMPONENTS)
|
|
16
|
+
if changed=='nonce': args['nonce']=U(secrets.token_bytes(16))
|
|
17
|
+
elif changed=='label': args['label']='other'
|
|
18
|
+
else: args[changed]-=1
|
|
19
|
+
headers=http_signatures.sign_request('POST',ctx.client.api+adapter.path('submit'),{'Content-Type':'application/json'},raw,**args)
|
|
20
|
+
r=ctx.client.submit(e,a.key,b,headers=headers)
|
|
21
|
+
require(400<=r.status<500,'HTTP/envelope mismatch accepted')
|
|
22
|
+
q=r.json()['entry']['payload']; require(q['reason_code'] in ('bad_signature','malformed'),'wrong HTTP-binding refusal')
|
|
23
|
+
# The transport binding is invalid. Do not accept asserted identity fields.
|
|
24
|
+
ctx.refused(r,e,q['reason_code'],authenticated=False)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from ..core import check
|
|
2
|
+
from ..protocol import Agent,J,H,U,DU,rotation
|
|
3
|
+
from ..schema import require
|
|
4
|
+
from .negative import post_attempt
|
|
5
|
+
|
|
6
|
+
@check('identity.claim-pop','Claim requires possession of claimed key','AICS-1 §7.1','accept_bad_signature')
|
|
7
|
+
def claim(ctx):
|
|
8
|
+
a=Agent(); e=ctx.prepare(a,'name.claim',a.claim()); e['sig']=U(bytes(64))
|
|
9
|
+
ctx.refused(ctx.client.submit(e,a.key),e,'bad_signature',authenticated=False)
|
|
10
|
+
|
|
11
|
+
@check('identity.rotate','Rotation requires both proofs and succeeds with both','AICS-1 §7.2','one_signature')
|
|
12
|
+
def rotate(ctx):
|
|
13
|
+
a=ctx.new_agent(); new=Agent(name=a.name)
|
|
14
|
+
for missing in ('sig_old','sig_new'):
|
|
15
|
+
q=rotation(a,new,ctx.policy.trust['origin']); q[missing]['signature']=U(bytes(64))
|
|
16
|
+
e=ctx.prepare(a,'key.rotate',q); ctx.refused(ctx.client.submit(e,a.key),e,'bad_signature')
|
|
17
|
+
entry,note=ctx.submit(a,'key.rotate',rotation(a,new,ctx.policy.trust['origin'])); i=ctx.follow(entry,note)
|
|
18
|
+
new.claim_index=a.claim_index; new.event_index=i; ctx.post(new)
|
|
19
|
+
hist,_=ctx.scan(); require(hist.names[a.name]['kid']==new.kid,'new signing key not current')
|
|
20
|
+
|
|
21
|
+
@check('identity.fork','Second event consuming a key predecessor is refused','AICS-1 §7.2','key_fork')
|
|
22
|
+
def fork(ctx):
|
|
23
|
+
a=ctx.new_agent(); predecessor=a.event_index; new=Agent(name=a.name)
|
|
24
|
+
entry,note=ctx.submit(a,'key.rotate',rotation(a,new,ctx.policy.trust['origin'])); i=ctx.follow(entry,note)
|
|
25
|
+
# Current key signs, but deliberately consumes the already-used name predecessor.
|
|
26
|
+
new.event_index=predecessor
|
|
27
|
+
e=ctx.prepare(new,'key.rotate',rotation(new,Agent(name=a.name),ctx.policy.trust['origin']))
|
|
28
|
+
result=ctx.refused(ctx.client.submit(e,new.key),e,'key_fork')
|
|
29
|
+
require(result['payload']['conflict_index']==i,'fork refusal missing first successor')
|
|
30
|
+
|
|
31
|
+
@check('identity.revoke','Revocation permanently prevents reclaim and rotation','AICS-1 §7.3','revocation')
|
|
32
|
+
def revoke(ctx):
|
|
33
|
+
a=ctx.new_agent(); entry,note=ctx.submit(a,'key.revoke',dict(name_norm=a.name,prev_event_index=a.event_index,reason_code='retired')); a.event_index=ctx.follow(entry,note)
|
|
34
|
+
replacement=Agent(name=a.name); e=ctx.prepare(replacement,'name.claim',replacement.claim())
|
|
35
|
+
ctx.refused(ctx.client.submit(e,replacement.key),e,'revoked')
|
|
36
|
+
e=ctx.prepare(a,'key.rotate',rotation(a,replacement,ctx.policy.trust['origin'])); ctx.refused(ctx.client.submit(e,a.key),e,'revoked')
|
|
37
|
+
|
|
38
|
+
@check('identity.x25519','Encryption key rotates and lookup proves latest key','AICS-1 §7.3','xrotate')
|
|
39
|
+
def xrotate(ctx):
|
|
40
|
+
a=ctx.new_agent(); new=Agent(); entry,note=ctx.submit(a,'x25519.rotate',dict(name_norm=a.name,prev_event_index=a.event_index,new_x25519=new.x)); i=ctx.follow(entry,note)
|
|
41
|
+
hist,_=ctx.scan(); require(hist.names[a.name]['x']==new.x and hist.names[a.name]['key_index']==i,'encryption key did not advance')
|
|
42
|
+
lookup=ctx.client.get('name',name=a.name); require(lookup.get('x25519',new.x)==new.x,'API did not advance')
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from aics_tlog import inclusion_proof_from_tiles, consistency_proof_from_tiles, MerkleTree, verify_consistency
|
|
2
|
+
from ..core import check, Skip
|
|
3
|
+
from ..protocol import J, DU, DB, H
|
|
4
|
+
from ..schema import require
|
|
5
|
+
from .. import verify, adapter
|
|
6
|
+
|
|
7
|
+
@check('log.checkpoint','Checkpoint signature uses out-of-band key','AICS-1 §§6.2–6.4,14.1','bad_checkpoint')
|
|
8
|
+
def checkpoint(ctx):
|
|
9
|
+
h=ctx.head()
|
|
10
|
+
return dict(size=h.cp.size,checkpoint_hash=H(h.raw),freshness='witnessed' if ctx.policy.quorum else 'remote freshness unproven')
|
|
11
|
+
|
|
12
|
+
@check('log.consistency','Two current fetches extend the same history','AICS-1 §§6.1,14.2','rollback')
|
|
13
|
+
def consistency(ctx):
|
|
14
|
+
old=ctx.head(); a=ctx.new_agent(); ctx.post(a); entries,new=ctx.scan(validate=False)
|
|
15
|
+
verify.continuity(old,new,entries)
|
|
16
|
+
|
|
17
|
+
@check('log.proofs','Served proofs equal authenticated tile-derived proofs','AICS-1 §§6.1,12.1,14.2','bad_proof')
|
|
18
|
+
def proofs(ctx):
|
|
19
|
+
old=ctx.head(); ctx.new_agent(); entries,h=ctx.scan(validate=False); reader=ctx.tiles.authenticated(h.cp)
|
|
20
|
+
for i in sorted({0,h.cp.size-1,h.cp.size//2}):
|
|
21
|
+
r=ctx.client.get('inclusion',params=dict(index=i,size=h.cp.size)); require(r['index']==i and r['size']==h.cp.size,'proof coordinates')
|
|
22
|
+
require([DB(x) for x in r['hashes']]==inclusion_proof_from_tiles(reader,i,h.cp.size),'served inclusion differs from tiles')
|
|
23
|
+
for m in (0,old.cp.size,h.cp.size):
|
|
24
|
+
r=ctx.client.get('consistency',params={'from':m,'to':h.cp.size}); require(r['from']==m and r['to']==h.cp.size,'consistency coordinates')
|
|
25
|
+
require([DB(x) for x in r['hashes']]==consistency_proof_from_tiles(reader,m,h.cp.size),'served consistency differs from tiles')
|
|
26
|
+
|
|
27
|
+
@check('log.receipt','Exact receipted entry included within 60 seconds','AICS-1 §§10.1,14.4','dropped_receipt')
|
|
28
|
+
def receipt(ctx):
|
|
29
|
+
a=ctx.new_agent(); entry,note=ctx.post(a); i=ctx.follow(entry,note)
|
|
30
|
+
return dict(index=i,timing=ctx.evidence['timing_evidence'])
|
|
31
|
+
|
|
32
|
+
@check('log.tombstone','Tombstone hides only body and preserves exact leaf','AICS-1 §10.3','tombstone_delete')
|
|
33
|
+
def tombstone(ctx):
|
|
34
|
+
hist,h=ctx.scan(); candidates=[(i,e) for i,e in enumerate(hist.entries) if e['type']=='tombstone']
|
|
35
|
+
if ctx.policy.tombstone_index is not None: candidates=[(i,e) for i,e in candidates if i==ctx.policy.tombstone_index]
|
|
36
|
+
if not candidates: raise Skip('operator must prepare a lawful synthetic tombstone fixture; no administrative signing key required')
|
|
37
|
+
def digest_of(q): return q.get('body_hash',q.get('ct_hash'))
|
|
38
|
+
for i,e in candidates:
|
|
39
|
+
target=e['payload']['target_index']; b=ctx.client.get('entry',index=target)
|
|
40
|
+
require(DU(b['entry_bytes'])==hist.raw[target],'tombstone changed or deleted target leaf')
|
|
41
|
+
targetq=hist.entries[target]['payload']; digest=digest_of(targetq)
|
|
42
|
+
if digest:
|
|
43
|
+
require(b['body_or_ct'] is None,'tombstoned body still served in its bundle')
|
|
44
|
+
# Another entry that is not hidden may commit to the same bytes (two identical public bodies): the body
|
|
45
|
+
# service then serves them for that entry, and the hidden one is checked through its bundle only.
|
|
46
|
+
shared=[j for j,x in enumerate(hist.entries) if j!=target and digest_of(x['payload'])==digest and j not in hist.tombstones]
|
|
47
|
+
if shared: continue
|
|
48
|
+
r=ctx.client.body(digest); require(r.status in (404,410),'tombstoned body still served')
|
|
49
|
+
status=r.json(); require(status['tombstone_index']==i and status['reason_code']==e['payload']['reason_code'],'body status lacks tombstone evidence')
|
|
50
|
+
|
|
51
|
+
@check('log.metadata','Every entry bundle contains only closed metadata schemas','AICS-1 §§5.1–5.3,6.1','plaintext_field_leak')
|
|
52
|
+
def metadata(ctx):
|
|
53
|
+
hist,h=ctx.scan(); return dict(entries_scanned=len(hist.entries),checkpoint_hash=H(h.raw))
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import secrets
|
|
2
|
+
from ..core import check
|
|
3
|
+
from ..protocol import J,H,U,DU,seal
|
|
4
|
+
from ..schema import require
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def post_attempt(ctx,a,text='negative-case synthetic body',**kwargs):
|
|
8
|
+
b=J(dict(channel='conformance',title=None,text=text)); q=dict(channel_sha256=H(b'conformance'),parent_index=None,body_hash=H(b),size=len(b),title_sha256=None)
|
|
9
|
+
return ctx.prepare(a,'post',q,**kwargs),b
|
|
10
|
+
|
|
11
|
+
@check('negative.bad-signature','Bad signature is refused with authenticated refusal leaf','AICS-1 §§4,10.2,12.1','accept_bad_signature')
|
|
12
|
+
def bad_signature(ctx):
|
|
13
|
+
a=ctx.new_agent(); e,b=post_attempt(ctx,a); e['sig']=U(bytes(64)); r=ctx.client.submit(e,a.key,b)
|
|
14
|
+
ctx.refused(r,e,'bad_signature',authenticated=False)
|
|
15
|
+
|
|
16
|
+
@check('negative.replay','Identical request replay is refused and linked to conflict','AICS-1 §§4.3,10.2','accept_replay')
|
|
17
|
+
def replay(ctx):
|
|
18
|
+
a=ctx.new_agent(); e,b=post_attempt(ctx,a); r=ctx.client.submit(e,a.key,b); entry,note=ctx.accepted(r,a,e); i=ctx.follow(entry,note)
|
|
19
|
+
result=ctx.refused(ctx.client.submit(e,a.key,b),e,'replay'); q=result['payload']
|
|
20
|
+
require(q['conflict_index']==i and q['receipt_hash']==H(note),'replay evidence lost original acceptance/receipt')
|
|
21
|
+
|
|
22
|
+
@check('negative.stale','Stale created is refused with a leaf','AICS-1 §§4.3,10.2','accept_stale')
|
|
23
|
+
def stale(ctx):
|
|
24
|
+
a=ctx.new_agent(); e,b=post_attempt(ctx,a,created=int(ctx.now())-301)
|
|
25
|
+
# The envelope and HTTP signature remain cryptographically valid, though expired.
|
|
26
|
+
ctx.refused(ctx.client.submit(e,a.key,b),e,'expired')
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def private_attempt(ctx,*,clear=False):
|
|
30
|
+
a=ctx.new_agent(); recipient=ctx.new_agent(); nonce=U(secrets.token_bytes(16))
|
|
31
|
+
if clear:
|
|
32
|
+
grant,note=ctx.submit(recipient,'grant',dict(owner_kid=recipient.kid,sender_kid=a.kid,max=1,expires=int(ctx.now())+600,prev_grant_index=None)); gi=ctx.follow(grant,note)
|
|
33
|
+
body=b'private plaintext must be refused'; scheme='cleartext'; eph=None
|
|
34
|
+
else:
|
|
35
|
+
gi=recipient.claim_index; body=seal(b'closed inbox probe',DU(recipient.x),a.kid,recipient.kid,nonce); scheme='x25519-xsalsa20-poly1305'; eph=U(body[:32])
|
|
36
|
+
q=dict(to_kid=recipient.kid,to_key_leaf_index=recipient.claim_index,encryption=scheme,eph_pk=eph,ct_hash=H(body),size=len(body),seq=1,prev=H(J(a.previous)),grant_index=gi)
|
|
37
|
+
return a,ctx.prepare(a,'dm',q,nonce=nonce),body
|
|
38
|
+
|
|
39
|
+
@check('negative.cleartext','Cleartext private message refused and logged','AICS-1 §§8.1,10.2','cleartext')
|
|
40
|
+
def cleartext(ctx):
|
|
41
|
+
a,e,b=private_attempt(ctx,clear=True); ctx.refused(ctx.client.submit(e,a.key,b),e,'unsupported_scheme')
|
|
42
|
+
|
|
43
|
+
@check('negative.closed-inbox','New inbox is closed; rejected message leaves evidence','AICS-1 §§9,10.2','open_inbox')
|
|
44
|
+
def closed(ctx):
|
|
45
|
+
a,e,b=private_attempt(ctx); ctx.refused(ctx.client.submit(e,a.key,b),e,'no_consent')
|
|
46
|
+
|
|
47
|
+
@check('negative.secret','Published secret format refused, logged, body unavailable','AICS-1 §10.2','retain_secret')
|
|
48
|
+
def secret(ctx):
|
|
49
|
+
a=ctx.new_agent(); card=ctx.client.get('card',pages=True)
|
|
50
|
+
require(ctx.policy.secret_pattern_id in {p['id'] for p in card['secret_patterns']},'configured secret test format is not published')
|
|
51
|
+
e,b=post_attempt(ctx,a,ctx.policy.secret_sample)
|
|
52
|
+
ctx.refused(ctx.client.submit(e,a.key,b),e,'secret_format:'+ctx.policy.secret_pattern_id)
|
|
53
|
+
r=ctx.client.body(H(b)); require(r.status in (404,410),'refused secret body retained by body service')
|
|
54
|
+
raw,h=ctx.scan(validate=False)
|
|
55
|
+
for entry in raw: require(ctx.policy.secret_sample.encode() not in entry and U(b).encode() not in entry,'refused body retained in leaf')
|
|
56
|
+
return dict(retention_evidence='Body service and complete leaves checked; inaccessible internal storage requires operator inspection.')
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from ..core import check,Skip
|
|
2
|
+
from ..card import verify_card
|
|
3
|
+
from ..schema import require
|
|
4
|
+
from .negative import post_attempt
|
|
5
|
+
@check('rates.published','Published per-key limit yields refusal leaves, never silence','AICS-1 §§10.2,13.1','rate_limit')
|
|
6
|
+
def rates(ctx):
|
|
7
|
+
card,hist,h=verify_card(ctx); limit=card['limits']['writes']; count=limit['count']
|
|
8
|
+
if count+1>ctx.policy.max_rate_attempts: raise Skip('published limit exceeds max_rate_attempts')
|
|
9
|
+
a=ctx.new_agent(); start=ctx.now(); refused=False
|
|
10
|
+
for i in range(count+1):
|
|
11
|
+
e,b=post_attempt(ctx,a); r=ctx.client.submit(e,a.key,b)
|
|
12
|
+
require(ctx.now()-start<limit['interval_seconds'],'incomplete: requests did not fit one rate window')
|
|
13
|
+
if r.status==202: ctx.accepted(r,a,e)
|
|
14
|
+
else:
|
|
15
|
+
require(i>=count-1,'rate refusal before published allowance was consumed')
|
|
16
|
+
ctx.refused(r,e,'rate_limit'); refused=True; break
|
|
17
|
+
require(refused,'published rate bound exceeded without a refusal leaf')
|
|
18
|
+
other=ctx.new_agent(); entry,note=ctx.post(other); ctx.follow(entry,note)
|