continuity-receipt 0.2.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.
- continuity_receipt/__init__.py +31 -0
- continuity_receipt/bundle.py +63 -0
- continuity_receipt/canon.py +42 -0
- continuity_receipt/disclose.py +218 -0
- continuity_receipt/keys.py +115 -0
- continuity_receipt/records.py +151 -0
- continuity_receipt/verify.py +394 -0
- continuity_receipt-0.2.0.dist-info/METADATA +100 -0
- continuity_receipt-0.2.0.dist-info/RECORD +13 -0
- continuity_receipt-0.2.0.dist-info/WHEEL +5 -0
- continuity_receipt-0.2.0.dist-info/entry_points.txt +3 -0
- continuity_receipt-0.2.0.dist-info/licenses/LICENSE +202 -0
- continuity_receipt-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Continuity Receipt v0 — reference implementation.
|
|
2
|
+
|
|
3
|
+
Spec: WHITEMAGIC/planning/specs/CONTINUITY_RECEIPT_v0_SPEC.md
|
|
4
|
+
Envelope spec id: continuity-receipt/0.1
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .canon import canonical_bytes, commit_field, sha256_prefixed
|
|
8
|
+
from .records import (
|
|
9
|
+
RECORD_TYPES,
|
|
10
|
+
REQUIRED_FIELDS,
|
|
11
|
+
new_envelope,
|
|
12
|
+
sign_receipt,
|
|
13
|
+
validate_body,
|
|
14
|
+
)
|
|
15
|
+
from .verify import VerifyResult, verify_bundle
|
|
16
|
+
|
|
17
|
+
SPEC_ID = "continuity-receipt/0.1"
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"SPEC_ID",
|
|
21
|
+
"RECORD_TYPES",
|
|
22
|
+
"REQUIRED_FIELDS",
|
|
23
|
+
"VerifyResult",
|
|
24
|
+
"canonical_bytes",
|
|
25
|
+
"commit_field",
|
|
26
|
+
"new_envelope",
|
|
27
|
+
"sha256_prefixed",
|
|
28
|
+
"sign_receipt",
|
|
29
|
+
"validate_body",
|
|
30
|
+
"verify_bundle",
|
|
31
|
+
]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Task chains and bundles (0.1 + 0.2).
|
|
2
|
+
|
|
3
|
+
Chain-link rule (freezes a spec ambiguity): `prev` and signatures are both
|
|
4
|
+
computed over the canonical bytes of the receipt **excluding** the `sig`
|
|
5
|
+
member. One canonicalization rule for both.
|
|
6
|
+
"""
|
|
7
|
+
from . import records
|
|
8
|
+
from .canon import canonical_bytes, sha256_prefixed
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def receipt_digest(receipt: dict) -> str:
|
|
12
|
+
return sha256_prefixed(canonical_bytes(records.unsigned_view(receipt)))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TaskChain:
|
|
16
|
+
def __init__(self, task_id: str | None = None, spec: str | None = None, ms_timestamps: bool = False):
|
|
17
|
+
self.task_id = task_id or ("urn:uuid:" + str(records.uuid7()))
|
|
18
|
+
self.spec = spec or records.SPEC_ID
|
|
19
|
+
self.ms_timestamps = ms_timestamps
|
|
20
|
+
self.receipts: list[dict] = []
|
|
21
|
+
|
|
22
|
+
def add(
|
|
23
|
+
self,
|
|
24
|
+
record_type: str,
|
|
25
|
+
issuer_kind: str,
|
|
26
|
+
issuer_did: str,
|
|
27
|
+
private_key,
|
|
28
|
+
body: dict,
|
|
29
|
+
) -> dict:
|
|
30
|
+
prev = receipt_digest(self.receipts[-1]) if self.receipts else None
|
|
31
|
+
receipt = records.new_envelope(
|
|
32
|
+
self.task_id,
|
|
33
|
+
issuer_kind,
|
|
34
|
+
issuer_did,
|
|
35
|
+
record_type,
|
|
36
|
+
len(self.receipts),
|
|
37
|
+
prev,
|
|
38
|
+
body,
|
|
39
|
+
spec=self.spec,
|
|
40
|
+
issued_at=records.utc_now_rfc3339(ms=self.ms_timestamps),
|
|
41
|
+
)
|
|
42
|
+
receipt = records.sign_receipt(receipt, private_key, issuer_did)
|
|
43
|
+
self.receipts.append(receipt)
|
|
44
|
+
return receipt
|
|
45
|
+
|
|
46
|
+
def bundle(
|
|
47
|
+
self,
|
|
48
|
+
disclosure_map: dict | None = None,
|
|
49
|
+
anchors: list | None = None,
|
|
50
|
+
revocations: list | None = None,
|
|
51
|
+
) -> dict:
|
|
52
|
+
bundle = {
|
|
53
|
+
"spec": self.spec,
|
|
54
|
+
"task_id": self.task_id,
|
|
55
|
+
"receipts": self.receipts,
|
|
56
|
+
}
|
|
57
|
+
if disclosure_map:
|
|
58
|
+
bundle["disclosure_map"] = disclosure_map
|
|
59
|
+
if anchors:
|
|
60
|
+
bundle["anchors"] = anchors
|
|
61
|
+
if revocations:
|
|
62
|
+
bundle["revocations"] = revocations
|
|
63
|
+
return bundle
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""JCS-subset canonicalization for Continuity Receipts (v0)."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def canonical_bytes(obj) -> bytes:
|
|
7
|
+
"""Deterministic bytes for signing and hashing.
|
|
8
|
+
|
|
9
|
+
v0 subset: sorted keys, no whitespace, UTF-8, integers/strings/bools/null
|
|
10
|
+
only; floats are rejected. Key sort is by Unicode code point (Python) vs
|
|
11
|
+
UTF-16 code units (JCS) — identical for ASCII keys, which the schema
|
|
12
|
+
requires. Full RFC 8785 edge cases tracked as spec §11 open item 1.
|
|
13
|
+
"""
|
|
14
|
+
_reject_floats(obj)
|
|
15
|
+
return json.dumps(
|
|
16
|
+
obj, separators=(",", ":"), ensure_ascii=False, sort_keys=True
|
|
17
|
+
).encode("utf-8")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _reject_floats(obj) -> None:
|
|
21
|
+
if isinstance(obj, float):
|
|
22
|
+
raise ValueError("floats are not allowed in continuity receipts (v0)")
|
|
23
|
+
if isinstance(obj, dict):
|
|
24
|
+
for key, value in obj.items():
|
|
25
|
+
if not isinstance(key, str):
|
|
26
|
+
raise ValueError("object keys must be strings")
|
|
27
|
+
_reject_floats(value)
|
|
28
|
+
elif isinstance(obj, (list, tuple)):
|
|
29
|
+
for value in obj:
|
|
30
|
+
_reject_floats(value)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def sha256_prefixed(data: bytes) -> str:
|
|
34
|
+
return "sha256:" + hashlib.sha256(data).hexdigest()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def commit_field(salt_hex: str, value) -> str:
|
|
38
|
+
"""v0 reference commitment: sha256(salt || '|' || JCS(value)).
|
|
39
|
+
|
|
40
|
+
Freezes spec §11 open item 2 for the v0 reference implementation.
|
|
41
|
+
"""
|
|
42
|
+
return sha256_prefixed(bytes.fromhex(salt_hex) + b"|" + canonical_bytes(value))
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Selective disclosure tooling for Continuity Receipt bundles (P1).
|
|
2
|
+
|
|
3
|
+
Redact optional fields with salted commitments, keep the salt+value map
|
|
4
|
+
separate, and merge a map back into a bundle for verification. Path grammar
|
|
5
|
+
matches the verifier: `receipts[i].body.<field>[.<nested>...]`.
|
|
6
|
+
|
|
7
|
+
CLI:
|
|
8
|
+
python3 -m continuity_receipt.disclose redact --bundle b.json \
|
|
9
|
+
--path receipts[3].body.spec_ref --out redacted.json --map map.json
|
|
10
|
+
python3 -m continuity_receipt.disclose verify --bundle redacted.json [--map map.json]
|
|
11
|
+
python3 -m continuity_receipt.disclose reveal --bundle redacted.json \
|
|
12
|
+
--map map.json --path receipts[3].body.spec_ref --out package.json
|
|
13
|
+
"""
|
|
14
|
+
import argparse
|
|
15
|
+
import copy
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from . import records
|
|
23
|
+
from .bundle import receipt_digest
|
|
24
|
+
from .canon import commit_field
|
|
25
|
+
from .verify import _required_field_for_path, verify_bundle
|
|
26
|
+
|
|
27
|
+
_INDEX = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\[(\d+)\]$")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _tokens(path: str) -> list:
|
|
31
|
+
tokens = path.split(".")
|
|
32
|
+
if not tokens or any(not token for token in tokens):
|
|
33
|
+
raise ValueError(f"malformed path: {path!r}")
|
|
34
|
+
return tokens
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _descend(node, token: str):
|
|
38
|
+
match = _INDEX.match(token)
|
|
39
|
+
if match:
|
|
40
|
+
return node[match.group(1)][int(match.group(2))]
|
|
41
|
+
return node[token]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _walk(node, path: str):
|
|
45
|
+
for token in _tokens(path):
|
|
46
|
+
node = _descend(node, token)
|
|
47
|
+
return node
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _walk_parent(node, path: str):
|
|
51
|
+
tokens = _tokens(path)
|
|
52
|
+
for token in tokens[:-1]:
|
|
53
|
+
node = _descend(node, token)
|
|
54
|
+
return node, tokens[-1]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _set(node, path: str, value) -> None:
|
|
58
|
+
parent, key = _walk_parent(node, path)
|
|
59
|
+
match = _INDEX.match(key)
|
|
60
|
+
if match:
|
|
61
|
+
parent[match.group(1)][int(match.group(2))] = value
|
|
62
|
+
else:
|
|
63
|
+
parent[key] = value
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _receipts(bundle: dict) -> list:
|
|
67
|
+
receipts = bundle.get("receipts")
|
|
68
|
+
if not isinstance(receipts, list):
|
|
69
|
+
raise ValueError("bundle has no receipts list")
|
|
70
|
+
return receipts
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _receipt_index(path: str) -> int:
|
|
74
|
+
tokens = _tokens(path)
|
|
75
|
+
match = _INDEX.match(tokens[0])
|
|
76
|
+
if not match or match.group(1) != "receipts":
|
|
77
|
+
raise ValueError(f"path must start with receipts[i]: {path!r}")
|
|
78
|
+
return int(match.group(2))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _resign_tail(bundle: dict, start: int, signer) -> None:
|
|
82
|
+
"""Rebuild `prev` links and signatures from `start` to the end of the chain."""
|
|
83
|
+
receipts = _receipts(bundle)
|
|
84
|
+
key, did = signer
|
|
85
|
+
prev = receipt_digest(receipts[start - 1]) if start > 0 else None
|
|
86
|
+
for index in range(start, len(receipts)):
|
|
87
|
+
receipt = receipts[index]
|
|
88
|
+
issuer = receipt.get("issuer", {}).get("id")
|
|
89
|
+
if issuer != did:
|
|
90
|
+
raise ValueError(f"receipt {index} issuer {issuer!r} != signer {did!r}; cannot re-sign")
|
|
91
|
+
receipt["seq"] = index
|
|
92
|
+
receipt["prev"] = prev
|
|
93
|
+
receipt.pop("sig", None)
|
|
94
|
+
receipts[index] = records.sign_receipt(receipt, key, did)
|
|
95
|
+
prev = receipt_digest(receipts[index])
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def redact(bundle: dict, paths: list, salts: dict | None = None, signer=None):
|
|
99
|
+
"""Replace each optional field with a commitment; return (redacted, map).
|
|
100
|
+
|
|
101
|
+
Redaction is an issuance-time act: the modified receipts (and everything
|
|
102
|
+
after them) are re-signed with `signer=(private_key, did)`, matching the
|
|
103
|
+
test-vector model (vectors 07/08).
|
|
104
|
+
"""
|
|
105
|
+
redacted = copy.deepcopy(bundle)
|
|
106
|
+
receipts = _receipts(redacted)
|
|
107
|
+
disclosure = {}
|
|
108
|
+
modified = set()
|
|
109
|
+
for path in paths:
|
|
110
|
+
if _required_field_for_path(path, receipts) is not None:
|
|
111
|
+
raise ValueError(f"required field cannot be redacted: {path}")
|
|
112
|
+
try:
|
|
113
|
+
value = _walk(redacted, path)
|
|
114
|
+
except (KeyError, IndexError, TypeError) as exc:
|
|
115
|
+
raise ValueError(f"path not found: {path}") from exc
|
|
116
|
+
salt = (salts or {}).get(path) or os.urandom(16).hex()
|
|
117
|
+
_set(redacted, path, {"redacted": True, "commit": commit_field(salt, value)})
|
|
118
|
+
disclosure[path] = {"salt": salt, "value": value}
|
|
119
|
+
modified.add(_receipt_index(path))
|
|
120
|
+
if modified:
|
|
121
|
+
if signer is None:
|
|
122
|
+
raise ValueError("redaction rewrites signed receipts; pass signer=(private_key, did)")
|
|
123
|
+
_resign_tail(redacted, min(modified), signer)
|
|
124
|
+
return redacted, disclosure
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def attach(bundle: dict, disclosure: dict) -> dict:
|
|
128
|
+
attached = copy.deepcopy(bundle)
|
|
129
|
+
merged = dict(attached.get("disclosure_map") or {})
|
|
130
|
+
merged.update(disclosure)
|
|
131
|
+
attached["disclosure_map"] = merged
|
|
132
|
+
return attached
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def reveal(redacted: dict, disclosure: dict, paths: list) -> dict:
|
|
136
|
+
"""Build a package disclosing only the requested paths from a full map."""
|
|
137
|
+
missing = [path for path in paths if path not in disclosure]
|
|
138
|
+
if missing:
|
|
139
|
+
raise ValueError(f"paths not in disclosure map: {missing}")
|
|
140
|
+
return attach(redacted, {path: disclosure[path] for path in paths})
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _load(path: str) -> dict:
|
|
144
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
145
|
+
return json.load(handle)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _dump(payload, path: str | None) -> None:
|
|
149
|
+
text = json.dumps(payload, indent=2, sort_keys=False)
|
|
150
|
+
if path:
|
|
151
|
+
with open(path, "w", encoding="utf-8") as handle:
|
|
152
|
+
handle.write(text + "\n")
|
|
153
|
+
else:
|
|
154
|
+
print(text)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def main(argv=None) -> int:
|
|
158
|
+
parser = argparse.ArgumentParser(prog="continuity-receipt-disclose")
|
|
159
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
160
|
+
|
|
161
|
+
redact_cmd = sub.add_parser("redact")
|
|
162
|
+
redact_cmd.add_argument("--bundle", required=True)
|
|
163
|
+
redact_cmd.add_argument("--path", action="append", required=True)
|
|
164
|
+
redact_cmd.add_argument("--out", required=True)
|
|
165
|
+
redact_cmd.add_argument("--map", required=True)
|
|
166
|
+
redact_cmd.add_argument("--gate-key", required=True, help="issuer private key file to re-sign the redacted tail")
|
|
167
|
+
|
|
168
|
+
verify_cmd = sub.add_parser("verify")
|
|
169
|
+
verify_cmd.add_argument("--bundle", required=True)
|
|
170
|
+
verify_cmd.add_argument("--map", default=None, help="attach this disclosure map before verifying")
|
|
171
|
+
verify_cmd.add_argument("--require-anchor", action="store_true")
|
|
172
|
+
|
|
173
|
+
reveal_cmd = sub.add_parser("reveal")
|
|
174
|
+
reveal_cmd.add_argument("--bundle", required=True)
|
|
175
|
+
reveal_cmd.add_argument("--map", required=True)
|
|
176
|
+
reveal_cmd.add_argument("--path", action="append", required=True)
|
|
177
|
+
reveal_cmd.add_argument("--out", required=True)
|
|
178
|
+
|
|
179
|
+
check_cmd = sub.add_parser("check")
|
|
180
|
+
check_cmd.add_argument("--salt", required=True)
|
|
181
|
+
check_cmd.add_argument("--value", required=True, help="JSON value")
|
|
182
|
+
check_cmd.add_argument("--commit", required=True)
|
|
183
|
+
|
|
184
|
+
args = parser.parse_args(argv)
|
|
185
|
+
|
|
186
|
+
if args.cmd == "redact":
|
|
187
|
+
from . import keys
|
|
188
|
+
|
|
189
|
+
key = keys.private_from_raw(Path(args.gate_key).read_bytes())
|
|
190
|
+
signer = (key, keys.pubkey_to_did_key(key.public_key()))
|
|
191
|
+
redacted, disclosure = redact(_load(args.bundle), args.path, signer=signer)
|
|
192
|
+
_dump(redacted, args.out)
|
|
193
|
+
_dump(disclosure, args.map)
|
|
194
|
+
return 0
|
|
195
|
+
if args.cmd == "verify":
|
|
196
|
+
bundle = _load(args.bundle)
|
|
197
|
+
if args.map:
|
|
198
|
+
bundle = attach(bundle, _load(args.map))
|
|
199
|
+
result = verify_bundle(bundle, require_anchor=args.require_anchor)
|
|
200
|
+
_dump(result.as_dict(), None)
|
|
201
|
+
return 0 if result.verdict == "TRUSTED" else 1
|
|
202
|
+
if args.cmd == "reveal":
|
|
203
|
+
bundle = _load(args.bundle)
|
|
204
|
+
package = reveal(bundle, _load(args.map), args.path)
|
|
205
|
+
_dump(package, args.out)
|
|
206
|
+
return 0
|
|
207
|
+
if args.cmd == "check":
|
|
208
|
+
value = json.loads(args.value)
|
|
209
|
+
expected = commit_field(args.salt, value)
|
|
210
|
+
ok = expected == args.commit
|
|
211
|
+
_dump({"match": ok, "expected": expected, "commit": args.commit}, None)
|
|
212
|
+
return 0 if ok else 1
|
|
213
|
+
parser.error("unknown command")
|
|
214
|
+
return 2
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
if __name__ == "__main__":
|
|
218
|
+
sys.exit(main())
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Ed25519 keys, signing, and did:key encoding for Continuity Receipts."""
|
|
2
|
+
import base64
|
|
3
|
+
import hashlib
|
|
4
|
+
import secrets
|
|
5
|
+
|
|
6
|
+
from cryptography.exceptions import InvalidSignature
|
|
7
|
+
from cryptography.hazmat.primitives import serialization
|
|
8
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
9
|
+
Ed25519PrivateKey,
|
|
10
|
+
Ed25519PublicKey,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
_B58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
|
|
14
|
+
_MULTICODEC_ED25519 = b"\xed\x01"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def b58encode(data: bytes) -> str:
|
|
18
|
+
number = int.from_bytes(data, "big")
|
|
19
|
+
encoded = ""
|
|
20
|
+
while number:
|
|
21
|
+
number, remainder = divmod(number, 58)
|
|
22
|
+
encoded = _B58_ALPHABET[remainder] + encoded
|
|
23
|
+
pad = 0
|
|
24
|
+
for byte in data:
|
|
25
|
+
if byte == 0:
|
|
26
|
+
pad += 1
|
|
27
|
+
else:
|
|
28
|
+
break
|
|
29
|
+
return "1" * pad + encoded
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def b58decode(text: str) -> bytes:
|
|
33
|
+
number = 0
|
|
34
|
+
for char in text:
|
|
35
|
+
number = number * 58 + _B58_ALPHABET.index(char)
|
|
36
|
+
raw = number.to_bytes((number.bit_length() + 7) // 8, "big")
|
|
37
|
+
pad = 0
|
|
38
|
+
for char in text:
|
|
39
|
+
if char == "1":
|
|
40
|
+
pad += 1
|
|
41
|
+
else:
|
|
42
|
+
break
|
|
43
|
+
return b"\x00" * pad + raw
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def b64u(data: bytes) -> str:
|
|
47
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def b64u_decode(text: str) -> bytes:
|
|
51
|
+
padding = "=" * (-len(text) % 4)
|
|
52
|
+
return base64.urlsafe_b64decode(text + padding)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def raw_pubkey_bytes(public: Ed25519PublicKey) -> bytes:
|
|
56
|
+
return public.public_bytes(
|
|
57
|
+
serialization.Encoding.Raw, serialization.PublicFormat.Raw
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def private_raw(private: Ed25519PrivateKey) -> bytes:
|
|
62
|
+
return private.private_bytes(
|
|
63
|
+
serialization.Encoding.Raw,
|
|
64
|
+
serialization.PrivateFormat.Raw,
|
|
65
|
+
serialization.NoEncryption(),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def private_from_raw(raw: bytes) -> Ed25519PrivateKey:
|
|
70
|
+
return Ed25519PrivateKey.from_private_bytes(raw)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def pubkey_to_did_key(pub) -> str:
|
|
74
|
+
raw = raw_pubkey_bytes(pub) if isinstance(pub, Ed25519PublicKey) else pub
|
|
75
|
+
return "did:key:z" + b58encode(_MULTICODEC_ED25519 + raw)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def did_key_to_pubkey(did: str) -> Ed25519PublicKey:
|
|
79
|
+
if not did.startswith("did:key:z"):
|
|
80
|
+
raise ValueError(f"unsupported did:key form: {did[:24]}")
|
|
81
|
+
raw = b58decode(did[len("did:key:z") :])
|
|
82
|
+
if not raw.startswith(_MULTICODEC_ED25519) or len(raw) != 34:
|
|
83
|
+
raise ValueError("did:key is not an Ed25519 key")
|
|
84
|
+
return Ed25519PublicKey.from_public_bytes(raw[2:])
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def generate(seed: bytes | None = None) -> tuple[str, Ed25519PrivateKey]:
|
|
88
|
+
"""Returns (did:key, private_key). Deterministic when seed is given."""
|
|
89
|
+
private = (
|
|
90
|
+
Ed25519PrivateKey.from_private_bytes(seed)
|
|
91
|
+
if seed is not None
|
|
92
|
+
else Ed25519PrivateKey.generate()
|
|
93
|
+
)
|
|
94
|
+
return pubkey_to_did_key(private.public_key()), private
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def sign(private: Ed25519PrivateKey, message: bytes) -> str:
|
|
98
|
+
return b64u(private.sign(message))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def verify(pubkey_did: str, message: bytes, signature_b64u: str) -> bool:
|
|
102
|
+
try:
|
|
103
|
+
did_key_to_pubkey(pubkey_did).verify(b64u_decode(signature_b64u), message)
|
|
104
|
+
return True
|
|
105
|
+
except (InvalidSignature, ValueError):
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def deterministic_seed(label: str) -> bytes:
|
|
110
|
+
"""Deterministic 32-byte seed for tests/vectors (never for production)."""
|
|
111
|
+
return hashlib.sha256(("continuity-receipt/" + label).encode()).digest()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def random_salt_hex() -> str:
|
|
115
|
+
return secrets.token_hex(16)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Receipt envelopes and body validation (0.1 + 0.2)."""
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import time
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
|
|
8
|
+
from . import keys
|
|
9
|
+
|
|
10
|
+
SPEC_ID = "continuity-receipt/0.2"
|
|
11
|
+
SUPPORTED_SPECS = ("continuity-receipt/0.1", "continuity-receipt/0.2")
|
|
12
|
+
|
|
13
|
+
RECORD_TYPES = (
|
|
14
|
+
"session.pass.created",
|
|
15
|
+
"task.decision",
|
|
16
|
+
"task.execution",
|
|
17
|
+
"delivery.attestation",
|
|
18
|
+
"task.termination",
|
|
19
|
+
"settlement",
|
|
20
|
+
"authority.succession",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
REQUIRED_FIELDS = {
|
|
24
|
+
"session.pass.created": (
|
|
25
|
+
"gate_id",
|
|
26
|
+
"mandala_class",
|
|
27
|
+
"quotas",
|
|
28
|
+
"expires_at",
|
|
29
|
+
"policy_version",
|
|
30
|
+
"mandate_ref",
|
|
31
|
+
"agent_id",
|
|
32
|
+
),
|
|
33
|
+
"task.decision": (
|
|
34
|
+
"action",
|
|
35
|
+
"action_args_hash",
|
|
36
|
+
"model",
|
|
37
|
+
"input_provenance",
|
|
38
|
+
"decision",
|
|
39
|
+
"policy_version",
|
|
40
|
+
),
|
|
41
|
+
"task.execution": ("tool_calls", "egress", "resources", "sandbox_class"),
|
|
42
|
+
"delivery.attestation": ("request_hash", "response_hash", "counterparty"),
|
|
43
|
+
"task.termination": ("reason", "limits_at_stop", "remaining"),
|
|
44
|
+
"settlement": ("rail", "rail_ref", "amount", "gated_on_delivery", "settled_at"),
|
|
45
|
+
"authority.succession": ("from_authority", "to_authority", "effective_at", "reason"),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# RFC 3339 UTC; fractional seconds optional (0.2 allows millisecond precision).
|
|
49
|
+
_TIMESTAMP_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def uuid7() -> uuid.UUID:
|
|
53
|
+
"""Time-ordered UUIDv7 (48-bit ms + random), stdlib-only."""
|
|
54
|
+
ms = int(time.time() * 1000)
|
|
55
|
+
rand = os.urandom(10)
|
|
56
|
+
raw = bytearray(16)
|
|
57
|
+
raw[0:6] = ms.to_bytes(6, "big")
|
|
58
|
+
raw[6] = 0x70 | (rand[0] & 0x0F)
|
|
59
|
+
raw[7] = rand[1]
|
|
60
|
+
raw[8] = 0x80 | (rand[2] & 0x3F)
|
|
61
|
+
raw[9:16] = rand[3:10]
|
|
62
|
+
return uuid.UUID(bytes=bytes(raw))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def utc_now_rfc3339(ms: bool = False) -> str:
|
|
66
|
+
now = datetime.now(timezone.utc)
|
|
67
|
+
if ms:
|
|
68
|
+
return now.strftime("%Y-%m-%dT%H:%M:%S.") + f"{now.microsecond // 1000:03d}Z"
|
|
69
|
+
return now.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def validate_timestamp(value) -> bool:
|
|
73
|
+
return isinstance(value, str) and bool(_TIMESTAMP_RE.match(value))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def parse_timestamp(value: str) -> datetime:
|
|
77
|
+
"""Parse a validated timestamp; second and millisecond precision compare correctly."""
|
|
78
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def validate_body(record_type: str, body: dict) -> None:
|
|
82
|
+
if record_type not in REQUIRED_FIELDS:
|
|
83
|
+
raise ValueError(f"unknown_type: {record_type}")
|
|
84
|
+
if not isinstance(body, dict):
|
|
85
|
+
raise ValueError(f"malformed body for {record_type}: not an object")
|
|
86
|
+
missing = [name for name in REQUIRED_FIELDS[record_type] if name not in body]
|
|
87
|
+
if missing:
|
|
88
|
+
raise ValueError(f"malformed body for {record_type}: missing {missing}")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def new_envelope(
|
|
92
|
+
task_id: str,
|
|
93
|
+
issuer_kind: str,
|
|
94
|
+
issuer_did: str,
|
|
95
|
+
record_type: str,
|
|
96
|
+
seq: int,
|
|
97
|
+
prev: str | None,
|
|
98
|
+
body: dict,
|
|
99
|
+
spec: str | None = None,
|
|
100
|
+
issued_at: str | None = None,
|
|
101
|
+
) -> dict:
|
|
102
|
+
validate_body(record_type, body)
|
|
103
|
+
return {
|
|
104
|
+
"spec": spec or SPEC_ID,
|
|
105
|
+
"receipt_id": "urn:uuid:" + str(uuid7()),
|
|
106
|
+
"task_id": task_id,
|
|
107
|
+
"issued_at": issued_at or utc_now_rfc3339(),
|
|
108
|
+
"issuer": {"kind": issuer_kind, "id": issuer_did},
|
|
109
|
+
"type": record_type,
|
|
110
|
+
"seq": seq,
|
|
111
|
+
"prev": prev,
|
|
112
|
+
"body": body,
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def unsigned_view(receipt: dict) -> dict:
|
|
117
|
+
return {key: value for key, value in receipt.items() if key != "sig"}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def attestation_view(body: dict) -> dict:
|
|
121
|
+
"""The view a counterparty attestation signs: body without counterparty.attestation."""
|
|
122
|
+
view = {key: value for key, value in body.items()}
|
|
123
|
+
counterparty = view.get("counterparty")
|
|
124
|
+
if isinstance(counterparty, dict):
|
|
125
|
+
view["counterparty"] = {
|
|
126
|
+
key: value for key, value in counterparty.items() if key != "attestation"
|
|
127
|
+
}
|
|
128
|
+
return view
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def sign_receipt(receipt: dict, private_key, key_did: str) -> dict:
|
|
132
|
+
from .canon import canonical_bytes
|
|
133
|
+
|
|
134
|
+
signed = dict(receipt)
|
|
135
|
+
signed["sig"] = {
|
|
136
|
+
"alg": "ed25519",
|
|
137
|
+
"key": key_did,
|
|
138
|
+
"value": keys.sign(private_key, canonical_bytes(unsigned_view(receipt))),
|
|
139
|
+
}
|
|
140
|
+
return signed
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def sign_body_attestation(body: dict, private_key, key_did: str) -> dict:
|
|
144
|
+
"""Counterparty attestation over the body minus the attestation member itself."""
|
|
145
|
+
from .canon import canonical_bytes
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
"alg": "ed25519",
|
|
149
|
+
"key": key_did,
|
|
150
|
+
"value": keys.sign(private_key, canonical_bytes(attestation_view(body))),
|
|
151
|
+
}
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
"""Continuity Receipt bundle verification (0.1 + 0.2).
|
|
2
|
+
|
|
3
|
+
Verdicts: TRUSTED | PROVISIONAL | INSUFFICIENT_EVIDENCE | UNTRUSTED
|
|
4
|
+
(IETF CTQ-aligned semantics; see spec §7).
|
|
5
|
+
"""
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from dataclasses import dataclass, field as dc_field
|
|
9
|
+
|
|
10
|
+
from . import keys, records
|
|
11
|
+
from .bundle import receipt_digest
|
|
12
|
+
from .canon import canonical_bytes, commit_field
|
|
13
|
+
|
|
14
|
+
ANCHOR_TYPES = ("opentimestamps", "public-chain", "custom")
|
|
15
|
+
PROVENANCE_PREFIXES = ("sha256:", "merkle-sha256:")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class VerifyResult:
|
|
20
|
+
verdict: str = "TRUSTED"
|
|
21
|
+
errors: list = dc_field(default_factory=list)
|
|
22
|
+
provisional_reasons: list = dc_field(default_factory=list)
|
|
23
|
+
insufficient_reasons: list = dc_field(default_factory=list)
|
|
24
|
+
summary: dict = dc_field(default_factory=dict)
|
|
25
|
+
|
|
26
|
+
def codes(self) -> list[str]:
|
|
27
|
+
return [entry["code"] for entry in self.errors]
|
|
28
|
+
|
|
29
|
+
def as_dict(self) -> dict:
|
|
30
|
+
return {
|
|
31
|
+
"verdict": self.verdict,
|
|
32
|
+
"errors": self.errors,
|
|
33
|
+
"provisional_reasons": self.provisional_reasons,
|
|
34
|
+
"insufficient_reasons": self.insufficient_reasons,
|
|
35
|
+
"summary": self.summary,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _fatal(result: VerifyResult, code: str, detail: str, receipt_id: str | None = None):
|
|
40
|
+
result.errors.append({"code": code, "detail": detail, "receipt_id": receipt_id})
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _iter_redactions(node, path, out):
|
|
44
|
+
if isinstance(node, dict):
|
|
45
|
+
if node.get("redacted") is True:
|
|
46
|
+
out.append((path, node))
|
|
47
|
+
return
|
|
48
|
+
for key, value in node.items():
|
|
49
|
+
_iter_redactions(value, f"{path}.{key}" if path else key, out)
|
|
50
|
+
elif isinstance(node, list):
|
|
51
|
+
for index, value in enumerate(node):
|
|
52
|
+
_iter_redactions(value, f"{path}[{index}]", out)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def verify_bundle(bundle: dict, require_anchor: bool = False) -> VerifyResult:
|
|
56
|
+
result = VerifyResult()
|
|
57
|
+
|
|
58
|
+
if not isinstance(bundle, dict):
|
|
59
|
+
_fatal(result, "malformed", "bundle is not an object")
|
|
60
|
+
return _finish(result)
|
|
61
|
+
|
|
62
|
+
if bundle.get("spec") not in records.SUPPORTED_SPECS:
|
|
63
|
+
_fatal(result, "version_unsupported", f"spec={bundle.get('spec')!r}")
|
|
64
|
+
return _finish(result)
|
|
65
|
+
|
|
66
|
+
receipts = bundle.get("receipts")
|
|
67
|
+
if not isinstance(receipts, list) or not receipts:
|
|
68
|
+
_fatal(result, "malformed", "bundle has no receipts")
|
|
69
|
+
return _finish(result)
|
|
70
|
+
|
|
71
|
+
task_id = bundle.get("task_id")
|
|
72
|
+
expected_prev = None
|
|
73
|
+
type_by_seq: dict[int, str] = {}
|
|
74
|
+
|
|
75
|
+
for index, receipt in enumerate(receipts):
|
|
76
|
+
if not isinstance(receipt, dict):
|
|
77
|
+
_fatal(result, "malformed", f"receipt {index} is not an object")
|
|
78
|
+
continue
|
|
79
|
+
rid = receipt.get("receipt_id")
|
|
80
|
+
if receipt.get("spec") not in records.SUPPORTED_SPECS:
|
|
81
|
+
_fatal(result, "version_unsupported", f"receipt spec={receipt.get('spec')!r}", rid)
|
|
82
|
+
if receipt.get("task_id") != task_id:
|
|
83
|
+
_fatal(result, "task_mismatch", "receipt task_id != bundle task_id", rid)
|
|
84
|
+
record_type = receipt.get("type")
|
|
85
|
+
if record_type not in records.RECORD_TYPES:
|
|
86
|
+
_fatal(result, "unknown_type", f"type={record_type!r}", rid)
|
|
87
|
+
continue
|
|
88
|
+
type_by_seq[index] = record_type
|
|
89
|
+
body = receipt.get("body")
|
|
90
|
+
if not isinstance(body, dict):
|
|
91
|
+
_fatal(result, "malformed", "body is not an object", rid)
|
|
92
|
+
continue
|
|
93
|
+
missing = [name for name in records.REQUIRED_FIELDS[record_type] if name not in body]
|
|
94
|
+
if missing:
|
|
95
|
+
_fatal(result, "malformed", f"missing body fields {missing}", rid)
|
|
96
|
+
|
|
97
|
+
if not records.validate_timestamp(receipt.get("issued_at")):
|
|
98
|
+
_fatal(result, "malformed", f"issued_at not RFC 3339 UTC: {receipt.get('issued_at')!r}", rid)
|
|
99
|
+
|
|
100
|
+
if receipt.get("seq") != index:
|
|
101
|
+
_fatal(result, "chain_break", f"seq {receipt.get('seq')} != position {index}", rid)
|
|
102
|
+
if receipt.get("prev") != expected_prev:
|
|
103
|
+
_fatal(result, "chain_break", "prev digest mismatch", rid)
|
|
104
|
+
expected_prev = receipt_digest(receipt)
|
|
105
|
+
|
|
106
|
+
sig = receipt.get("sig")
|
|
107
|
+
if not isinstance(sig, dict) or sig.get("alg") != "ed25519" or not sig.get("value"):
|
|
108
|
+
_fatal(result, "bad_signature", "missing or unsupported sig", rid)
|
|
109
|
+
else:
|
|
110
|
+
issuer = receipt.get("issuer", {}).get("id", "")
|
|
111
|
+
message = canonical_bytes(records.unsigned_view(receipt))
|
|
112
|
+
if not keys.verify(issuer, message, sig["value"]):
|
|
113
|
+
_fatal(result, "bad_signature", "signature does not verify", rid)
|
|
114
|
+
|
|
115
|
+
_check_cross_record(result, receipts, type_by_seq)
|
|
116
|
+
_check_redactions(result, receipts, bundle.get("disclosure_map") or {})
|
|
117
|
+
_check_attestations(result, receipts)
|
|
118
|
+
_check_provenance(result, receipts)
|
|
119
|
+
_check_revocations(result, bundle, receipts)
|
|
120
|
+
_check_anchors(result, bundle, receipts, require_anchor)
|
|
121
|
+
|
|
122
|
+
summary = {
|
|
123
|
+
"receipts": len(receipts),
|
|
124
|
+
"types": [r.get("type") for r in receipts if isinstance(r, dict)],
|
|
125
|
+
"issuers": sorted(
|
|
126
|
+
{r.get("issuer", {}).get("id", "") for r in receipts if isinstance(r, dict)}
|
|
127
|
+
),
|
|
128
|
+
"terminated": "task.termination" in type_by_seq.values(),
|
|
129
|
+
"settled": "settlement" in type_by_seq.values(),
|
|
130
|
+
}
|
|
131
|
+
result.summary = {**summary, **result.summary}
|
|
132
|
+
return _finish(result)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _check_cross_record(result: VerifyResult, receipts: list, type_by_seq: dict) -> None:
|
|
136
|
+
pass_receipts = [r for r in receipts if r.get("type") == "session.pass.created"]
|
|
137
|
+
if not pass_receipts:
|
|
138
|
+
_fatal(result, "malformed", "chain has no session.pass.created receipt")
|
|
139
|
+
return
|
|
140
|
+
pass_body = pass_receipts[0]["body"]
|
|
141
|
+
policy_version = pass_body.get("policy_version")
|
|
142
|
+
|
|
143
|
+
for receipt in receipts:
|
|
144
|
+
if receipt.get("type") == "task.decision":
|
|
145
|
+
if receipt["body"].get("policy_version") != policy_version:
|
|
146
|
+
_fatal(
|
|
147
|
+
result,
|
|
148
|
+
"policy_mismatch",
|
|
149
|
+
f"decision policy {receipt['body'].get('policy_version')!r} "
|
|
150
|
+
f"!= pass policy {policy_version!r}",
|
|
151
|
+
receipt.get("receipt_id"),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
spend_cap = pass_body.get("spend_cap")
|
|
155
|
+
settlement_indexes = [i for i, t in type_by_seq.items() if t == "settlement"]
|
|
156
|
+
delivery_indexes = [i for i, t in type_by_seq.items() if t == "delivery.attestation"]
|
|
157
|
+
|
|
158
|
+
for index in settlement_indexes:
|
|
159
|
+
settlement = receipts[index]
|
|
160
|
+
amount = settlement["body"].get("amount", {})
|
|
161
|
+
if spend_cap is not None and (
|
|
162
|
+
amount.get("currency") != spend_cap.get("currency")
|
|
163
|
+
or int(amount.get("minor", 0)) > int(spend_cap.get("minor", 0))
|
|
164
|
+
):
|
|
165
|
+
_fatal(
|
|
166
|
+
result,
|
|
167
|
+
"cap_exceeded",
|
|
168
|
+
f"settlement {amount} exceeds cap {spend_cap}",
|
|
169
|
+
settlement.get("receipt_id"),
|
|
170
|
+
)
|
|
171
|
+
if settlement["body"].get("gated_on_delivery") and (
|
|
172
|
+
not delivery_indexes or min(delivery_indexes) > index
|
|
173
|
+
):
|
|
174
|
+
_fatal(
|
|
175
|
+
result,
|
|
176
|
+
"delivery_before_settlement",
|
|
177
|
+
"gated settlement recorded before any delivery attestation",
|
|
178
|
+
settlement.get("receipt_id"),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
if "task.termination" not in type_by_seq.values():
|
|
182
|
+
_fatal(result, "missing_termination", "task has no termination receipt")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _check_redactions(result: VerifyResult, receipts: list, disclosure_map: dict) -> None:
|
|
186
|
+
redactions: list[tuple[str, dict]] = []
|
|
187
|
+
_iter_redactions(receipts, "receipts", redactions)
|
|
188
|
+
for path, field in redactions:
|
|
189
|
+
if _required_field_for_path(path, receipts):
|
|
190
|
+
_fatal(result, "redacted_required", f"required field redacted at {path}")
|
|
191
|
+
continue
|
|
192
|
+
entry = disclosure_map.get(path)
|
|
193
|
+
if entry and "salt" in entry and "value" in entry:
|
|
194
|
+
if commit_field(entry["salt"], entry["value"]) != field.get("commit"):
|
|
195
|
+
_fatal(result, "commit_mismatch", f"commit mismatch at {path}")
|
|
196
|
+
continue
|
|
197
|
+
if field.get("erased"):
|
|
198
|
+
result.insufficient_reasons.append(f"erased_content:{path}")
|
|
199
|
+
else:
|
|
200
|
+
result.provisional_reasons.append(f"redacted_without_disclosure:{path}")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _check_attestations(result: VerifyResult, receipts: list) -> None:
|
|
204
|
+
"""0.2: counterparty attestations are verified per-signature and reported.
|
|
205
|
+
|
|
206
|
+
Absence of an attestation is visible in the summary but does not change the
|
|
207
|
+
verdict (documented in the threat model; counterparty id alone is the
|
|
208
|
+
minimum required evidence).
|
|
209
|
+
"""
|
|
210
|
+
seen = []
|
|
211
|
+
for receipt in receipts:
|
|
212
|
+
if receipt.get("type") != "delivery.attestation":
|
|
213
|
+
continue
|
|
214
|
+
body = receipt.get("body", {})
|
|
215
|
+
counterparty = body.get("counterparty", {}) if isinstance(body, dict) else {}
|
|
216
|
+
attestation = counterparty.get("attestation") if isinstance(counterparty, dict) else None
|
|
217
|
+
if not attestation:
|
|
218
|
+
seen.append(
|
|
219
|
+
{
|
|
220
|
+
"receipt_id": receipt.get("receipt_id"),
|
|
221
|
+
"counterparty": counterparty.get("id") if isinstance(counterparty, dict) else None,
|
|
222
|
+
"attestation": "absent",
|
|
223
|
+
}
|
|
224
|
+
)
|
|
225
|
+
continue
|
|
226
|
+
valid = (
|
|
227
|
+
isinstance(attestation, dict)
|
|
228
|
+
and attestation.get("alg") == "ed25519"
|
|
229
|
+
and isinstance(attestation.get("key"), str)
|
|
230
|
+
and isinstance(attestation.get("value"), str)
|
|
231
|
+
and keys.verify(
|
|
232
|
+
attestation["key"],
|
|
233
|
+
canonical_bytes(records.attestation_view(body)),
|
|
234
|
+
attestation["value"],
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
seen.append(
|
|
238
|
+
{
|
|
239
|
+
"receipt_id": receipt.get("receipt_id"),
|
|
240
|
+
"counterparty": counterparty.get("id") if isinstance(counterparty, dict) else None,
|
|
241
|
+
"attestation": "valid" if valid else "invalid",
|
|
242
|
+
"key": attestation.get("key") if isinstance(attestation, dict) else None,
|
|
243
|
+
}
|
|
244
|
+
)
|
|
245
|
+
if not valid:
|
|
246
|
+
_fatal(
|
|
247
|
+
result,
|
|
248
|
+
"bad_attestation",
|
|
249
|
+
"counterparty attestation does not verify",
|
|
250
|
+
receipt.get("receipt_id"),
|
|
251
|
+
)
|
|
252
|
+
if seen:
|
|
253
|
+
result.summary["attestations"] = seen
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _check_provenance(result: VerifyResult, receipts: list) -> None:
|
|
257
|
+
"""0.2: observed_sources_hash is a flat sha256 or a merkle-sha256 root."""
|
|
258
|
+
for receipt in receipts:
|
|
259
|
+
if receipt.get("type") != "task.decision":
|
|
260
|
+
continue
|
|
261
|
+
provenance = receipt.get("body", {}).get("input_provenance")
|
|
262
|
+
if not isinstance(provenance, dict):
|
|
263
|
+
continue
|
|
264
|
+
observed = provenance.get("observed_sources_hash")
|
|
265
|
+
if observed is None:
|
|
266
|
+
continue
|
|
267
|
+
if not isinstance(observed, str) or not observed.startswith(PROVENANCE_PREFIXES):
|
|
268
|
+
_fatal(
|
|
269
|
+
result,
|
|
270
|
+
"provenance_invalid",
|
|
271
|
+
f"observed_sources_hash has unsupported form: {observed!r}",
|
|
272
|
+
receipt.get("receipt_id"),
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _check_revocations(result: VerifyResult, bundle: dict, receipts: list) -> None:
|
|
277
|
+
"""0.2: bundle-level revocation statements (self-signed by the revoked key).
|
|
278
|
+
|
|
279
|
+
A receipt is UNTRUSTED (`key_revoked`) when its issuer key was revoked at or
|
|
280
|
+
before the receipt's issued_at. Receipts issued before revocation remain
|
|
281
|
+
valid; invalid revocation statements are themselves an error (fail-closed).
|
|
282
|
+
"""
|
|
283
|
+
revocations = bundle.get("revocations") or []
|
|
284
|
+
if not isinstance(revocations, list):
|
|
285
|
+
_fatal(result, "bad_revocation", "revocations must be a list")
|
|
286
|
+
return
|
|
287
|
+
revoked: list[tuple[str, object]] = []
|
|
288
|
+
checked = 0
|
|
289
|
+
for statement in revocations:
|
|
290
|
+
if not isinstance(statement, dict):
|
|
291
|
+
_fatal(result, "bad_revocation", "revocation statement is not an object")
|
|
292
|
+
continue
|
|
293
|
+
key_id, revoked_at, sig = statement.get("key"), statement.get("revoked_at"), statement.get("sig")
|
|
294
|
+
if not isinstance(key_id, str) or not records.validate_timestamp(revoked_at):
|
|
295
|
+
_fatal(result, "bad_revocation", f"malformed revocation statement for {key_id!r}")
|
|
296
|
+
continue
|
|
297
|
+
if not isinstance(sig, dict) or sig.get("alg") != "ed25519" or not sig.get("value"):
|
|
298
|
+
_fatal(result, "bad_revocation", f"revocation statement unsigned for {key_id!r}")
|
|
299
|
+
continue
|
|
300
|
+
message = canonical_bytes({k: v for k, v in statement.items() if k != "sig"})
|
|
301
|
+
if not keys.verify(key_id, message, sig["value"]):
|
|
302
|
+
_fatal(result, "bad_revocation", f"revocation signature invalid for {key_id!r}")
|
|
303
|
+
continue
|
|
304
|
+
revoked.append((key_id, records.parse_timestamp(revoked_at)))
|
|
305
|
+
checked += 1
|
|
306
|
+
|
|
307
|
+
for receipt in receipts:
|
|
308
|
+
key_id = receipt.get("issuer", {}).get("id")
|
|
309
|
+
issued = receipt.get("issued_at")
|
|
310
|
+
if not isinstance(key_id, str) or not records.validate_timestamp(issued):
|
|
311
|
+
continue
|
|
312
|
+
issued_at = records.parse_timestamp(issued)
|
|
313
|
+
for revoked_key, revoked_at in revoked:
|
|
314
|
+
if revoked_key == key_id and issued_at >= revoked_at:
|
|
315
|
+
_fatal(
|
|
316
|
+
result,
|
|
317
|
+
"key_revoked",
|
|
318
|
+
f"issuer key {key_id} was revoked at {revoked_at.isoformat()}",
|
|
319
|
+
receipt.get("receipt_id"),
|
|
320
|
+
)
|
|
321
|
+
if revocations:
|
|
322
|
+
result.summary["revocations_checked"] = checked
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _required_field_for_path(path: str, receipts: list) -> str | None:
|
|
326
|
+
parts = path.split(".")
|
|
327
|
+
if len(parts) >= 3 and parts[0].startswith("receipts[") and parts[1] == "body":
|
|
328
|
+
index_text = parts[0][len("receipts[") :].rstrip("]")
|
|
329
|
+
try:
|
|
330
|
+
receipt = receipts[int(index_text)]
|
|
331
|
+
except (ValueError, IndexError):
|
|
332
|
+
return None
|
|
333
|
+
record_type = receipt.get("type")
|
|
334
|
+
if record_type in records.REQUIRED_FIELDS and parts[2] in records.REQUIRED_FIELDS[record_type]:
|
|
335
|
+
return parts[2]
|
|
336
|
+
return None
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _check_anchors(result: VerifyResult, bundle: dict, receipts: list, require_anchor: bool) -> None:
|
|
340
|
+
anchors = bundle.get("anchors")
|
|
341
|
+
if not anchors:
|
|
342
|
+
if require_anchor:
|
|
343
|
+
result.provisional_reasons.append("anchor_missing")
|
|
344
|
+
return
|
|
345
|
+
by_id = {r.get("receipt_id"): r for r in receipts if isinstance(r, dict)}
|
|
346
|
+
kinds = []
|
|
347
|
+
for anchor in anchors:
|
|
348
|
+
target = by_id.get(anchor.get("target"))
|
|
349
|
+
if target is None or anchor.get("hash") != receipt_digest(target):
|
|
350
|
+
_fatal(result, "anchor_invalid", f"anchor invalid for {anchor.get('target')}")
|
|
351
|
+
continue
|
|
352
|
+
meta = anchor.get("anchor")
|
|
353
|
+
if meta is not None:
|
|
354
|
+
if not isinstance(meta, dict) or meta.get("type") not in ANCHOR_TYPES:
|
|
355
|
+
_fatal(
|
|
356
|
+
result,
|
|
357
|
+
"anchor_invalid",
|
|
358
|
+
f"unknown anchor type: {meta.get('type') if isinstance(meta, dict) else meta!r}",
|
|
359
|
+
)
|
|
360
|
+
continue
|
|
361
|
+
kinds.append(meta.get("type"))
|
|
362
|
+
if anchors:
|
|
363
|
+
result.summary["anchors"] = kinds or ["hash-only"]
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _finish(result: VerifyResult) -> VerifyResult:
|
|
367
|
+
if result.errors:
|
|
368
|
+
result.verdict = "UNTRUSTED"
|
|
369
|
+
elif result.insufficient_reasons:
|
|
370
|
+
result.verdict = "INSUFFICIENT_EVIDENCE"
|
|
371
|
+
elif result.provisional_reasons:
|
|
372
|
+
result.verdict = "PROVISIONAL"
|
|
373
|
+
else:
|
|
374
|
+
result.verdict = "TRUSTED"
|
|
375
|
+
return result
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def main(argv=None) -> int:
|
|
379
|
+
import argparse
|
|
380
|
+
|
|
381
|
+
parser = argparse.ArgumentParser(prog="continuity-receipt-verify")
|
|
382
|
+
parser.add_argument("bundle", help="path to a bundle JSON file")
|
|
383
|
+
parser.add_argument("--require-anchor", action="store_true")
|
|
384
|
+
args = parser.parse_args(argv)
|
|
385
|
+
|
|
386
|
+
with open(args.bundle, "r", encoding="utf-8") as handle:
|
|
387
|
+
bundle = json.load(handle)
|
|
388
|
+
result = verify_bundle(bundle, require_anchor=args.require_anchor)
|
|
389
|
+
print(json.dumps(result.as_dict(), indent=2))
|
|
390
|
+
return 0 if result.verdict == "TRUSTED" else 1
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
if __name__ == "__main__":
|
|
394
|
+
sys.exit(main())
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: continuity-receipt
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Continuity Receipt — open specification, test vectors, and reference verifier for verifiable records of governed AI-agent tasks
|
|
5
|
+
Author: Lucas Bailey
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/lbailey94/continuity-receipt
|
|
8
|
+
Project-URL: Repository, https://github.com/lbailey94/continuity-receipt
|
|
9
|
+
Project-URL: Issues, https://github.com/lbailey94/continuity-receipt/issues
|
|
10
|
+
Project-URL: Specification, https://github.com/lbailey94/continuity-receipt/blob/main/SPEC.md
|
|
11
|
+
Keywords: agent-memory,provenance,verification,receipts,interoperability,ed25519
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Security :: Cryptography
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: cryptography>=42
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# Continuity Receipt
|
|
24
|
+
|
|
25
|
+
**An open specification, test vectors, and reference verifier for verifiable records of governed AI-agent tasks.**
|
|
26
|
+
|
|
27
|
+
**Spec version:** `continuity-receipt/0.2` — published 2026-09-18 (`0.1` remains supported; open items listed in `SPEC.md` §11).
|
|
28
|
+
**License:** Apache-2.0 (specification text, code, and vectors).
|
|
29
|
+
|
|
30
|
+
A Continuity Receipt is a signed, hash-chained record of one governed task:
|
|
31
|
+
**decision → authority → execution → delivery → termination → settlement**. It is designed to be verified offline by any third party — insurers, arbiters, procurement, courts, other agents — **without requiring trust in the issuer**.
|
|
32
|
+
|
|
33
|
+
The spec is intentionally small: JSON (RFC 8785 canonicalization), SHA-256, Ed25519, `did:key` identities. No new cryptography. Redaction uses salted commitments so fields can be revealed selectively without breaking integrity; erasure makes commitments opaque while the chain still verifies.
|
|
34
|
+
|
|
35
|
+
## Why this exists
|
|
36
|
+
|
|
37
|
+
Agents are accumulating persistent memory and doing delegated work at scale, but accountability is still self-reported. This document is the opposite: claims arrive with evidence, missing evidence is distinguished from false evidence, and "proof it stopped" (`task.termination`) is required for a task to verify as complete.
|
|
38
|
+
|
|
39
|
+
Verification verdicts (IETF CTQ-aligned):
|
|
40
|
+
`TRUSTED` · `PROVISIONAL` · `INSUFFICIENT_EVIDENCE` · `UNTRUSTED`.
|
|
41
|
+
`INSUFFICIENT_EVIDENCE` is explicitly not the same as false.
|
|
42
|
+
|
|
43
|
+
## Layout
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
SPEC.md the v0.2 specification (normative; 0.1 supported)
|
|
47
|
+
schema/ JSON Schema (2020-12) for 0.1 + 0.2 bundles
|
|
48
|
+
THREAT_MODEL.md what receipts prove, and what they do not
|
|
49
|
+
ANCHORING.md anchoring policy: OpenTimestamps default, chain optional
|
|
50
|
+
CONTRIBUTING.md DCO, test rules, scope
|
|
51
|
+
ROADMAP.md what lands in 0.3 and beyond, and the selection rule
|
|
52
|
+
continuity_receipt/ reference implementation (Python, cryptography>=42)
|
|
53
|
+
vectors/ 20 test vectors + INDEX.md + manifest.json
|
|
54
|
+
tools/make_vectors.py regenerates the vectors deterministically
|
|
55
|
+
tests/ conformance suite (vectors, schema, primitives)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Quickstart
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
python3 -m venv .venv && . .venv/bin/activate
|
|
62
|
+
pip install 'cryptography>=42'
|
|
63
|
+
|
|
64
|
+
# verify a vector (TRUSTED / PROVISIONAL / INSUFFICIENT_EVIDENCE / UNTRUSTED)
|
|
65
|
+
python3 -m continuity_receipt.verify vectors/02_happy_full.json
|
|
66
|
+
|
|
67
|
+
# vectors that require an anchor policy
|
|
68
|
+
python3 -m continuity_receipt.verify vectors/10b_anchor_missing.json --require-anchor
|
|
69
|
+
|
|
70
|
+
# run the conformance suite (11/11 expected)
|
|
71
|
+
python3 -m unittest discover -s tests -v
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Test vectors
|
|
75
|
+
|
|
76
|
+
20 vectors with machine-readable expectations in `vectors/manifest.json`
|
|
77
|
+
(human index: `vectors/INDEX.md`): 0.1 conformance (`01`–`10c`) plus 0.2
|
|
78
|
+
additions — succession records, millisecond timestamps, counterparty
|
|
79
|
+
attestations, revocation semantics, Merkle provenance, and anchor typing.
|
|
80
|
+
Every schema-valid vector is also checked against
|
|
81
|
+
`schema/continuity-receipt-0.2.schema.json` in CI.
|
|
82
|
+
|
|
83
|
+
## Status and provenance
|
|
84
|
+
|
|
85
|
+
- **Origin:** developed in the MandalaOS gate-lite work, where it passed acceptance G1–G8 and the wider project suite (49 tests, dogfood evidence). This repository is the format's public home; it versions independently of any product release train.
|
|
86
|
+
- **Releases:** `0.1` (2026-09-18) — spec, reference verifier, 11 vectors. `0.2` (2026-09-18) — `authority.succession`, bundle-level revocation statements, counterparty attestation rules, millisecond timestamps, `merkle-sha256:` provenance, anchor typing, JSON Schema, CI, machine-readable vector manifest.
|
|
87
|
+
- **Origin implementation:** [WhiteMagic](https://github.com/lbailey94/whitemagic) — an MIT, local-first memory substrate for agents (this spec repo is Apache-2.0; the two are separate works).
|
|
88
|
+
- **Standards context:** the format is intended as a contribution to the emerging neutral layer (W3C AI Agent Memory Interoperability CG; IETF agentproto work). It is not endorsed by those bodies, and no claim of adoption is made.
|
|
89
|
+
|
|
90
|
+
## Development disclosure
|
|
91
|
+
|
|
92
|
+
This project is developed with AI agents as drafting, implementation, and review collaborators, under the direction and accountability of the human maintainer. Every artifact published here — spec text, code, vectors — is reviewed and signed off by the human maintainer, who is responsible for its claims. We disclose this proactively because verifiability is the project's subject as well as its method.
|
|
93
|
+
|
|
94
|
+
## Contributing
|
|
95
|
+
|
|
96
|
+
Open an issue for spec questions, mapping suggestions, or implementation feedback. Interoperability discussion belongs in the open standards venues; this repository tracks concrete text and vectors.
|
|
97
|
+
|
|
98
|
+
## License
|
|
99
|
+
|
|
100
|
+
Apache License 2.0 — see `LICENSE`. The specification is published for royalty-free implementation. Test keys in the vectors are deterministic and public; **never use them for real receipts**.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
continuity_receipt/__init__.py,sha256=B3bBWpL0Wbn2kp868cvnMmUyk-fiJ8TH4CiVzfa3r4c,674
|
|
2
|
+
continuity_receipt/bundle.py,sha256=pH_k2FH4OIcTDmNETcrPP8Ess9W7jIxrB02QCzfO9WA,1979
|
|
3
|
+
continuity_receipt/canon.py,sha256=rIdwwqUTS9dOvPK_sIbsgNM7WPoJ5niF8UUJB8VriFE,1465
|
|
4
|
+
continuity_receipt/disclose.py,sha256=RPkoeSIlOEv5IZWTEj9ToJx4bRvg9s5bJ62oyqtru4o,7837
|
|
5
|
+
continuity_receipt/keys.py,sha256=IPYhzAMxid6rWXatYbcd_BjhR96d-HX2mSDBmnNY7KA,3399
|
|
6
|
+
continuity_receipt/records.py,sha256=UOofCw9wdlZKxX1SfqjJw4dJaNbmkgdcqkR7uFLEoj0,4571
|
|
7
|
+
continuity_receipt/verify.py,sha256=T-uSRU4O4jUIwaklNbRgZeb0sYoDcwr9-mjaVQKS3g0,16197
|
|
8
|
+
continuity_receipt-0.2.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
|
|
9
|
+
continuity_receipt-0.2.0.dist-info/METADATA,sha256=j7pQZ0seFBRhOoDZ7i7tXkR7yScTu1divgwye0zRsuc,5995
|
|
10
|
+
continuity_receipt-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
11
|
+
continuity_receipt-0.2.0.dist-info/entry_points.txt,sha256=0nSOYn_TDdejA9o9JEEG38ONVa6aDvTlxLjSn4l1kHI,140
|
|
12
|
+
continuity_receipt-0.2.0.dist-info/top_level.txt,sha256=MJqGcFpj13pQPvgskI-o33iw2u7Wk5FvLQM-p4bANgk,19
|
|
13
|
+
continuity_receipt-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
14
|
+
the copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
17
|
+
other entities that control, are controlled by, or are under common
|
|
18
|
+
control with that entity. For the purposes of this definition,
|
|
19
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
20
|
+
direction or management of such entity, whether by contract or
|
|
21
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
22
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, including
|
|
50
|
+
the original version of the Work and any modifications or additions
|
|
51
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
52
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
53
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
54
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
55
|
+
means any form of electronic, verbal, or written communication sent
|
|
56
|
+
to the Licensor or its representatives, including but not limited to
|
|
57
|
+
communication on electronic mailing lists, source code control systems,
|
|
58
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
59
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
60
|
+
excluding communication that is conspicuously marked or otherwise
|
|
61
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
62
|
+
|
|
63
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
64
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
65
|
+
subsequently incorporated within the Work.
|
|
66
|
+
|
|
67
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
68
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
69
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
70
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
71
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
72
|
+
Work and such Derivative Works in Source or Object form.
|
|
73
|
+
|
|
74
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
75
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
76
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
77
|
+
(except as stated in this section) patent license to make, have made,
|
|
78
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
79
|
+
where such license applies only to those patent claims licensable
|
|
80
|
+
by such Contributor that are necessarily infringed by their
|
|
81
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
82
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
83
|
+
institute patent litigation against any entity (including a
|
|
84
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
85
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
86
|
+
or contributory patent infringement, then any patent licenses
|
|
87
|
+
granted to You under this License for that Work shall terminate
|
|
88
|
+
as of the date such litigation is filed.
|
|
89
|
+
|
|
90
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
91
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
92
|
+
modifications, and in Source or Object form, provided that You
|
|
93
|
+
meet the following conditions:
|
|
94
|
+
|
|
95
|
+
(a) You must give any other recipients of the Work or
|
|
96
|
+
Derivative Works a copy of this License; and
|
|
97
|
+
|
|
98
|
+
(b) You must cause any modified files to carry prominent notices
|
|
99
|
+
stating that You changed the files; and
|
|
100
|
+
|
|
101
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
102
|
+
that You distribute, all copyright, patent, trademark, and
|
|
103
|
+
attribution notices from the Source form of the Work,
|
|
104
|
+
excluding those notices that do not pertain to any part of
|
|
105
|
+
the Derivative Works; and
|
|
106
|
+
|
|
107
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
108
|
+
distribution, then any Derivative Works that You distribute must
|
|
109
|
+
include a readable copy of the attribution notices contained
|
|
110
|
+
within such NOTICE file, excluding those notices that do not
|
|
111
|
+
pertain to any part of the Derivative Works, in at least one
|
|
112
|
+
of the following places: within a NOTICE text file distributed
|
|
113
|
+
as part of the Derivative Works; within the Source form or
|
|
114
|
+
documentation, if provided along with the Derivative Works; or,
|
|
115
|
+
within a display generated by the Derivative Works, if and
|
|
116
|
+
wherever such third-party notices normally appear. The contents
|
|
117
|
+
of the NOTICE file are for informational purposes only and
|
|
118
|
+
do not modify the License. You may add Your own attribution
|
|
119
|
+
notices within Derivative Works that You distribute, alongside
|
|
120
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
121
|
+
that such additional attribution notices cannot be construed
|
|
122
|
+
as modifying the License.
|
|
123
|
+
|
|
124
|
+
You may add Your own copyright statement to Your modifications and
|
|
125
|
+
may provide additional or different license terms and conditions
|
|
126
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
127
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
128
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
129
|
+
the conditions stated in this License.
|
|
130
|
+
|
|
131
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
132
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
133
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
134
|
+
this License, without any additional terms or conditions.
|
|
135
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
136
|
+
the terms of any separate license agreement you may have executed
|
|
137
|
+
with Licensor regarding such Contributions.
|
|
138
|
+
|
|
139
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
140
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
141
|
+
except as required for reasonable and customary use in describing the
|
|
142
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
143
|
+
|
|
144
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
145
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
146
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
147
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
148
|
+
implied, including, without limitation, any warranties or conditions
|
|
149
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
150
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
151
|
+
appropriateness of using or redistributing the Work and assume any
|
|
152
|
+
risks associated with Your exercise of permissions under this License.
|
|
153
|
+
|
|
154
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
155
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
156
|
+
unless required by applicable law (such as deliberate and grossly
|
|
157
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
158
|
+
liable to You for damages, including any direct, indirect, special,
|
|
159
|
+
incidental, or consequential damages of any character arising as a
|
|
160
|
+
result of this License or out of the use or inability to use the
|
|
161
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
162
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
163
|
+
other commercial damages or losses), even if such Contributor
|
|
164
|
+
has been advised of the possibility of such damages.
|
|
165
|
+
|
|
166
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
167
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
168
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
169
|
+
or other liability obligations and/or rights consistent with this
|
|
170
|
+
License. However, in accepting such obligations, You may act only
|
|
171
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
172
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
173
|
+
defend, and hold each Contributor harmless for any liability
|
|
174
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
175
|
+
of your accepting any such warranty or additional liability.
|
|
176
|
+
|
|
177
|
+
END OF TERMS AND CONDITIONS
|
|
178
|
+
|
|
179
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
180
|
+
|
|
181
|
+
To apply the Apache License to your work, attach the following
|
|
182
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
183
|
+
replaced with your own identifying information. (Don't include
|
|
184
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
185
|
+
comment syntax for the file format. We also recommend that a
|
|
186
|
+
file or class name and description of purpose be included on the
|
|
187
|
+
same "printed page" as the copyright notice for easier
|
|
188
|
+
identification within third-party archives.
|
|
189
|
+
|
|
190
|
+
Copyright [yyyy] [name of copyright owner]
|
|
191
|
+
|
|
192
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
|
+
you may not use this file except in compliance with the License.
|
|
194
|
+
You may obtain a copy of the License at
|
|
195
|
+
|
|
196
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
197
|
+
|
|
198
|
+
Unless required by applicable law or agreed to in writing, software
|
|
199
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
200
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
201
|
+
See the License for the specific language governing permissions and
|
|
202
|
+
limitations under the License.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
continuity_receipt
|