ccp-protocol 0.4.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.
ccp/canonical.py ADDED
@@ -0,0 +1,192 @@
1
+ """Strict canonical JSON per CCP §25.1.
2
+
3
+ The canonical form is deterministic and byte-exact:
4
+
5
+ * numbers: only non-negative integers; floats, exponents, negatives, NaN,
6
+ Infinity and -Infinity are rejected;
7
+ * strings: UTF-8, NFC-normalized (keys and values), minimal escaping;
8
+ * objects: keys other than ``signature`` sorted by Unicode code point;
9
+ duplicate keys rejected; ``signature`` always emitted last;
10
+ * arrays: order preserved; no insignificant whitespace;
11
+ * any JSON value is supported, including top-level scalars.
12
+ """
13
+
14
+ import hashlib
15
+ import json
16
+ import unicodedata
17
+
18
+
19
+ class CanonicalError(ValueError):
20
+ """Raised when a value cannot be represented in canonical form."""
21
+
22
+ def __init__(self, code):
23
+ self.code = code
24
+ super().__init__(code)
25
+
26
+
27
+ _SHORT_ESCAPES = {
28
+ "\b": "\\b",
29
+ "\f": "\\f",
30
+ "\n": "\\n",
31
+ "\r": "\\r",
32
+ "\t": "\\t",
33
+ }
34
+
35
+
36
+ def _require_well_formed(text):
37
+ """Reject strings containing unpaired surrogates (§25.1 UTF-8)."""
38
+ for char in text:
39
+ if 0xD800 <= ord(char) <= 0xDFFF:
40
+ raise CanonicalError("malformed")
41
+
42
+
43
+ def quote(s):
44
+ """Encode one string using the minimal escape set."""
45
+ _require_well_formed(s)
46
+ s = unicodedata.normalize("NFC", s)
47
+ out = ['"']
48
+ for ch in s:
49
+ if ch == '"':
50
+ out.append('\\"')
51
+ elif ch == "\\":
52
+ out.append("\\\\")
53
+ elif ch in _SHORT_ESCAPES:
54
+ out.append(_SHORT_ESCAPES[ch])
55
+ elif ord(ch) < 0x20:
56
+ out.append("\\u%04x" % ord(ch))
57
+ else:
58
+ out.append(ch)
59
+ out.append('"')
60
+ return "".join(out)
61
+
62
+
63
+ def serialize(value, _top=True):
64
+ """Serialize a parsed JSON value in canonical form."""
65
+ if value is None:
66
+ return "null"
67
+ if value is True:
68
+ return "true"
69
+ if value is False:
70
+ return "false"
71
+ if isinstance(value, str):
72
+ return quote(value)
73
+ if isinstance(value, int):
74
+ if value < 0:
75
+ raise CanonicalError("negative")
76
+ return str(value)
77
+ if isinstance(value, float):
78
+ raise CanonicalError("float")
79
+ if isinstance(value, list):
80
+ return "[" + ",".join(serialize(v, _top=False) for v in value) + "]"
81
+ if isinstance(value, dict):
82
+ if _top and "signature" in value:
83
+ regular = {k: v for k, v in value.items() if k != "signature"}
84
+ else:
85
+ regular = value
86
+ folded = {}
87
+ for key, item in regular.items():
88
+ nfc = unicodedata.normalize("NFC", key)
89
+ if nfc in folded:
90
+ raise CanonicalError("duplicate")
91
+ folded[nfc] = item
92
+ parts = [quote(k) + ":" + serialize(folded[k], _top=False)
93
+ for k in sorted(folded)]
94
+ if _top and "signature" in value:
95
+ parts.append(quote("signature") + ":"
96
+ + serialize(value["signature"], _top=False))
97
+ return "{" + ",".join(parts) + "}"
98
+ raise TypeError("unsupported type %r" % (type(value),))
99
+
100
+
101
+ def _reject_dupes(pairs):
102
+ seen = {}
103
+ for key, value in pairs:
104
+ if key in seen:
105
+ raise CanonicalError("duplicate")
106
+ seen[key] = value
107
+ return seen
108
+
109
+
110
+ def _reject_constant(_name):
111
+ raise CanonicalError("constant")
112
+
113
+
114
+ def _parse_int(text):
115
+ if text.startswith("-"):
116
+ raise CanonicalError("negative")
117
+ return int(text)
118
+
119
+
120
+ def _parse_float(text):
121
+ if text.startswith("-"):
122
+ raise CanonicalError("negative")
123
+ raise CanonicalError("float")
124
+
125
+
126
+ def _check(value):
127
+ """Validate decoded strings and NFC key uniqueness."""
128
+ if isinstance(value, str):
129
+ _require_well_formed(value)
130
+ elif isinstance(value, list):
131
+ for item in value:
132
+ _check(item)
133
+ elif isinstance(value, dict):
134
+ seen = set()
135
+ for key, item in value.items():
136
+ _require_well_formed(key)
137
+ folded = unicodedata.normalize("NFC", key)
138
+ if folded in seen:
139
+ raise CanonicalError("duplicate")
140
+ seen.add(folded)
141
+ _check(item)
142
+
143
+
144
+ def parse_strict(document):
145
+ """Parse a JSON document with duplicate-key and NaN/Infinity rejection."""
146
+ try:
147
+ value = json.loads(
148
+ document,
149
+ object_pairs_hook=_reject_dupes,
150
+ parse_constant=_reject_constant,
151
+ parse_int=_parse_int,
152
+ parse_float=_parse_float,
153
+ )
154
+ except json.JSONDecodeError as exc:
155
+ raise CanonicalError("malformed") from exc
156
+ _check(value)
157
+ return value
158
+
159
+
160
+ def canonicalize(document):
161
+ """Canonicalize a JSON document string."""
162
+ return serialize(parse_strict(document))
163
+
164
+
165
+ def signing_bytes(document):
166
+ """Canonical bytes signed for a document; ``signature`` excluded."""
167
+ obj = parse_strict(document)
168
+ obj.pop("signature", None)
169
+ return serialize(obj).encode("utf-8")
170
+
171
+
172
+ def signing_bytes_of(value):
173
+ """Canonical bytes signed for an already-parsed value."""
174
+ obj = dict(value)
175
+ obj.pop("signature", None)
176
+ return serialize(obj).encode("utf-8")
177
+
178
+
179
+ def signed_wire(document, signature_b64url):
180
+ """Wire form: canonical document with ``signature`` appended last."""
181
+ obj = parse_strict(document)
182
+ obj["signature"] = "ed25519:" + signature_b64url
183
+ return serialize(obj)
184
+
185
+
186
+ def digest_bytes(data):
187
+ return "sha256:" + hashlib.sha256(data).hexdigest()
188
+
189
+
190
+ def digest_of(document):
191
+ """`sha256:<hex>` over the canonical signing bytes of a document."""
192
+ return digest_bytes(signing_bytes(document))
ccp/consumer.py ADDED
@@ -0,0 +1,75 @@
1
+ """Consumer role: verify inbound documents and build permission cards."""
2
+
3
+ from dataclasses import asdict, dataclass
4
+
5
+ from .errors import CCPError
6
+ from .model import Manifest, Signal
7
+
8
+
9
+ def verify_manifest(document, expected_provider_id=None):
10
+ """Parse and verify a signed manifest (§8), returning the `Manifest`."""
11
+ manifest = Manifest.parse(document)
12
+ manifest.verify(expected_provider_id)
13
+ return manifest
14
+
15
+
16
+ def verify_signal(document, verifying_key):
17
+ """Parse and verify a signed signal (§10.1), returning the `Signal`."""
18
+ signal = Signal.parse(document)
19
+ signal.verify(verifying_key)
20
+ return signal
21
+
22
+
23
+ @dataclass
24
+ class PermissionCard:
25
+ """Plain-language permission card for a trusted UI (§11.4)."""
26
+
27
+ who: str
28
+ what: str
29
+ data_read: list
30
+ where: str
31
+ egress: str
32
+ effects: str
33
+ recurrence: str
34
+ revoke: str
35
+
36
+ def to_dict(self):
37
+ return asdict(self)
38
+
39
+
40
+ def permission_card(manifest, intent, *, provider_name=None, data_read=None,
41
+ where=None, effects=None, recurrence="once", revoke=None):
42
+ """Build the §11.4 card for one intent of a verified manifest."""
43
+ definition = (manifest.intents or {}).get(intent)
44
+ if definition is None:
45
+ raise CCPError("intent_not_allowed", "unknown intent %r" % (intent,))
46
+
47
+ who = provider_name or manifest.provider_name or manifest.provider_id
48
+ placement = definition.get("placement", "local")
49
+ egress_spec = definition.get("data_egress") or {}
50
+ network = egress_spec.get("network") is True
51
+ fields = egress_spec.get("fields") or []
52
+
53
+ if where is None:
54
+ where = ("on this device" if placement == "local"
55
+ else "via %s" % placement)
56
+ if network:
57
+ egress = "sends %s to a remote endpoint" % (
58
+ ", ".join(fields) or "declared fields")
59
+ else:
60
+ egress = "does not send data off the device"
61
+
62
+ return PermissionCard(
63
+ who=who,
64
+ what=definition.get("description", intent),
65
+ data_read=list(
66
+ data_read if data_read is not None
67
+ else definition.get("required_capabilities") or []
68
+ ),
69
+ where=where,
70
+ egress=egress,
71
+ effects=effects or "no external side effect declared",
72
+ recurrence=("applies to future requests" if recurrence == "future"
73
+ else "applies once"),
74
+ revoke=revoke or "you can revoke this grant at any time",
75
+ )
ccp/crypto.py ADDED
@@ -0,0 +1,96 @@
1
+ """Ed25519 key and signature encoding per CCP §25.2."""
2
+
3
+ import base64
4
+
5
+ from cryptography.exceptions import InvalidSignature
6
+ from cryptography.hazmat.primitives import serialization
7
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
8
+ Ed25519PrivateKey,
9
+ Ed25519PublicKey,
10
+ )
11
+
12
+ KEY_PREFIX = "key:ed25519:"
13
+ SIG_PREFIX = "ed25519:"
14
+
15
+
16
+ class CryptoError(ValueError):
17
+ pass
18
+
19
+
20
+ def b64url_encode(raw):
21
+ return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
22
+
23
+
24
+ def b64url_decode(text):
25
+ pad = "=" * (-len(text) % 4)
26
+ return base64.urlsafe_b64decode(text + pad)
27
+
28
+
29
+ def key_id_from_public_bytes(raw):
30
+ return KEY_PREFIX + b64url_encode(raw)
31
+
32
+
33
+ def public_bytes_from_key_id(key_id):
34
+ if not key_id.startswith(KEY_PREFIX):
35
+ raise CryptoError("unsupported key identifier: %r" % (key_id,))
36
+ return b64url_decode(key_id[len(KEY_PREFIX):])
37
+
38
+
39
+ class SigningKey:
40
+ """A 32-byte RFC 8032 seed and its Ed25519 signer."""
41
+
42
+ def __init__(self, private_key):
43
+ self._key = private_key
44
+
45
+ @classmethod
46
+ def generate(cls):
47
+ return cls(Ed25519PrivateKey.generate())
48
+
49
+ @classmethod
50
+ def from_seed(cls, seed):
51
+ if len(seed) != 32:
52
+ raise CryptoError("seed must be 32 bytes")
53
+ return cls(Ed25519PrivateKey.from_private_bytes(seed))
54
+
55
+ @property
56
+ def public_bytes(self):
57
+ return self._key.public_key().public_bytes(
58
+ serialization.Encoding.Raw, serialization.PublicFormat.Raw)
59
+
60
+ @property
61
+ def key_id(self):
62
+ return key_id_from_public_bytes(self.public_bytes)
63
+
64
+ def verifying_key(self):
65
+ return VerifyingKey(self._key.public_key())
66
+
67
+ def sign(self, message):
68
+ """Return the signature field value `ed25519:<base64url>`."""
69
+ return SIG_PREFIX + b64url_encode(self._key.sign(message))
70
+
71
+
72
+ class VerifyingKey:
73
+ def __init__(self, public_key):
74
+ self._key = public_key
75
+
76
+ @classmethod
77
+ def from_key_id(cls, key_id):
78
+ raw = public_bytes_from_key_id(key_id)
79
+ return cls(Ed25519PublicKey.from_public_bytes(raw))
80
+
81
+ @property
82
+ def key_id(self):
83
+ return key_id_from_public_bytes(
84
+ self._key.public_bytes(
85
+ serialization.Encoding.Raw, serialization.PublicFormat.Raw))
86
+
87
+ def verify(self, message, signature_field):
88
+ """Verify an `ed25519:<base64url>` signature over ``message``."""
89
+ if not isinstance(signature_field, str) or not signature_field.startswith(SIG_PREFIX):
90
+ raise CryptoError("unsupported signature encoding")
91
+ raw = b64url_decode(signature_field[len(SIG_PREFIX):])
92
+ try:
93
+ self._key.verify(raw, message)
94
+ return True
95
+ except InvalidSignature:
96
+ return False
ccp/decision.py ADDED
@@ -0,0 +1,81 @@
1
+ """The pure §10.3 validation decision.
2
+
3
+ Separated from the runtime so it can be tested directly against the frozen
4
+ validation vectors without performing side effects. The runtime calls this
5
+ and then applies the side effects (replay marking, limit reservation,
6
+ confirmation, execution) atomically.
7
+ """
8
+
9
+ from . import schema
10
+
11
+
12
+ def decide(grant, signal, signer, now, seen, counters, *,
13
+ intent_spec=None, params_schema=None, supported_ccp="0.4-draft"):
14
+ """Return the §10.3 decision: ``authorized``,
15
+ ``confirmation_required``, or a rejection code."""
16
+ # 3. protocol compatibility
17
+ if signal.get("ccp") != supported_ccp:
18
+ return "invalid_request"
19
+
20
+ # 4. locate the grant
21
+ if grant is None:
22
+ return "no_matching_grant"
23
+
24
+ # 2. authenticate the sender against the grant's requester
25
+ if signer != grant.requester_key:
26
+ return "unauthenticated"
27
+ if signal.get("grant_digest") != grant.manifest_digest:
28
+ return "no_matching_grant"
29
+
30
+ # 5. grant state and expiry (explicit lifecycle states first)
31
+ if grant.state == "revoked":
32
+ return "grant_revoked"
33
+ if grant.state == "paused":
34
+ return "grant_paused"
35
+ if grant.state == "suspended":
36
+ return "grant_suspended"
37
+ if grant.state == "expired" or now >= grant.expires_at:
38
+ return "grant_expired"
39
+
40
+ # 6. intent enabled
41
+ intent = signal.get("intent")
42
+ if intent not in grant.intents:
43
+ return "intent_not_allowed"
44
+
45
+ # 7. parameters against the approved schema
46
+ if intent_spec is None or params_schema is None:
47
+ return "capability_unavailable"
48
+ if not schema.is_valid(signal.get("params"), params_schema):
49
+ return "invalid_request"
50
+
51
+ # 8. request expiry and replay
52
+ if now >= signal.get("expires_at", ""):
53
+ return "request_expired"
54
+ if signal.get("request_id") in seen:
55
+ return "replay_detected"
56
+
57
+ # 9. cumulative limits
58
+ for name, maximum in (grant.limits or {}).items():
59
+ if counters.get(name, 0) + 1 > maximum:
60
+ return "limit_exceeded"
61
+
62
+ # 10. required host capabilities
63
+ required = intent_spec.get("required_capabilities", [])
64
+ if not set(required).issubset(set(grant.capabilities or [])):
65
+ return "capability_unavailable"
66
+
67
+ # 11. confirmation policy
68
+ if confirmation_policy(grant, intent_spec, intent) == "per_action":
69
+ return "confirmation_required"
70
+
71
+ return "authorized"
72
+
73
+
74
+ def confirmation_policy(grant, intent_spec, intent_name):
75
+ policy = (grant.confirmation or {}).get(intent_name)
76
+ if policy in ("none", "per_action"):
77
+ return policy
78
+ policy = intent_spec.get("confirmation")
79
+ if policy in ("none", "per_action"):
80
+ return policy
81
+ return "per_action"
ccp/errors.py ADDED
@@ -0,0 +1,35 @@
1
+ """Typed error codes and exceptions for CCP (§10.4)."""
2
+
3
+ AUTHORIZED = "authorized"
4
+ CONFIRMATION_REQUIRED = "confirmation_required"
5
+
6
+ #: Rejection codes from §10.4. ``confirmation_required`` is a transient
7
+ #: success, not a rejection, and is handled separately.
8
+ ERROR_CODES = frozenset({
9
+ "invalid_request",
10
+ "unauthenticated",
11
+ "no_matching_grant",
12
+ "intent_not_allowed",
13
+ "grant_expired",
14
+ "grant_revoked",
15
+ "grant_paused",
16
+ "grant_suspended",
17
+ "limit_exceeded",
18
+ "request_expired",
19
+ "replay_detected",
20
+ "capability_unavailable",
21
+ "execution_failed",
22
+ "outcome_unknown",
23
+ })
24
+
25
+
26
+ class CCPError(Exception):
27
+ """An error carrying one of the §10.4 codes."""
28
+
29
+ def __init__(self, code, message=""):
30
+ if code not in ERROR_CODES:
31
+ raise ValueError("unknown CCP error code: %r" % (code,))
32
+ self.code = code
33
+ self.message = message
34
+ detail = "%s: %s" % (code, message) if message else code
35
+ super().__init__(detail)
ccp/hosts.py ADDED
@@ -0,0 +1,52 @@
1
+ """Host-function registry and per-request privilege mediation (§26.4).
2
+
3
+ The runtime passes an :class:`HostAccess` proxy to an intent executor. The
4
+ proxy exposes only the host functions granted to the requester, so an
5
+ executor cannot reach capabilities outside the grant.
6
+ """
7
+
8
+
9
+ class HostAccess:
10
+ def __init__(self, registry, granted):
11
+ self._registry = registry
12
+ self._granted = set(granted)
13
+
14
+ def call(self, name, *args, **kwargs):
15
+ if name not in self._granted:
16
+ raise PermissionError("host function not granted: %s" % (name,))
17
+ function = self._registry.host(name)
18
+ if function is None:
19
+ raise PermissionError("host function not installed: %s" % (name,))
20
+ return function(*args, **kwargs)
21
+
22
+ def has(self, name):
23
+ return name in self._granted and self._registry.host(name) is not None
24
+
25
+
26
+ class HostRegistry:
27
+ """Installed intents and their underlying host functions."""
28
+
29
+ def __init__(self):
30
+ self._hosts = {}
31
+ self._intents = {}
32
+
33
+ def register_host(self, name, function):
34
+ self._hosts[name] = function
35
+
36
+ def register_intent(self, name, function):
37
+ self._intents[name] = function
38
+
39
+ def host(self, name):
40
+ return self._hosts.get(name)
41
+
42
+ def has_host(self, name):
43
+ return name in self._hosts
44
+
45
+ def intent(self, name):
46
+ return self._intents.get(name)
47
+
48
+ def has_intent(self, name):
49
+ return name in self._intents
50
+
51
+ def access(self, granted):
52
+ return HostAccess(self, granted)