trace-verify 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.
- trace_verify/__init__.py +12 -0
- trace_verify/__main__.py +169 -0
- trace_verify/_verify.py +77 -0
- trace_verify-0.1.0.dist-info/METADATA +127 -0
- trace_verify-0.1.0.dist-info/RECORD +8 -0
- trace_verify-0.1.0.dist-info/WHEEL +4 -0
- trace_verify-0.1.0.dist-info/entry_points.txt +2 -0
- trace_verify-0.1.0.dist-info/licenses/LICENSE +6 -0
trace_verify/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""trace-verify: verify TRACE claim inclusion proofs against the public registry.
|
|
2
|
+
|
|
3
|
+
Standard library only. Anchor format v1 (docs/anchor-format.md).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
__anchor_format_version__ = 1
|
|
10
|
+
__all__ = ["verify_inclusion", "canonical_claim_bytes"]
|
|
11
|
+
|
|
12
|
+
from trace_verify._verify import canonical_claim_bytes, verify_inclusion
|
trace_verify/__main__.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""CLI entry point for trace-verify.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
trace-verify --claim CLAIM.json --proof PROOF.json --entry ENTRY.ndjson
|
|
5
|
+
trace-verify --claim CLAIM.json --proof PROOF.json --entry-url URL
|
|
6
|
+
python -m trace_verify ...
|
|
7
|
+
|
|
8
|
+
Exit code 0: claim is proven included.
|
|
9
|
+
Exit code 1: proof does not verify.
|
|
10
|
+
Exit code 2: bad arguments or unreadable files.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
import urllib.error
|
|
19
|
+
import urllib.request
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from trace_verify import __version__
|
|
23
|
+
from trace_verify._verify import decode_hash, verify_inclusion
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _load_json_file(path: Path) -> object:
|
|
27
|
+
try:
|
|
28
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
29
|
+
except OSError as exc:
|
|
30
|
+
_die(f"cannot read {path}: {exc}")
|
|
31
|
+
except json.JSONDecodeError as exc:
|
|
32
|
+
_die(f"invalid JSON in {path}: {exc}")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _fetch_url(url: str) -> str:
|
|
36
|
+
try:
|
|
37
|
+
with urllib.request.urlopen(url, timeout=15) as resp: # noqa: S310
|
|
38
|
+
return resp.read().decode("utf-8")
|
|
39
|
+
except urllib.error.URLError as exc:
|
|
40
|
+
_die(f"cannot fetch {url}: {exc}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _load_entry(source: str, batch_id: str | None) -> dict:
|
|
44
|
+
"""Load a registry entry from a local file path or a URL."""
|
|
45
|
+
if source.startswith("https://") or source.startswith("http://"):
|
|
46
|
+
raw = _fetch_url(source)
|
|
47
|
+
else:
|
|
48
|
+
path = Path(source)
|
|
49
|
+
try:
|
|
50
|
+
raw = path.read_text(encoding="utf-8")
|
|
51
|
+
except OSError as exc:
|
|
52
|
+
_die(f"cannot read {source}: {exc}")
|
|
53
|
+
|
|
54
|
+
lines = [ln for ln in raw.splitlines() if ln.strip()]
|
|
55
|
+
entries = []
|
|
56
|
+
for ln in lines:
|
|
57
|
+
try:
|
|
58
|
+
entries.append(json.loads(ln))
|
|
59
|
+
except json.JSONDecodeError as exc:
|
|
60
|
+
_die(f"invalid JSON line in entry source: {exc}")
|
|
61
|
+
|
|
62
|
+
if batch_id is not None:
|
|
63
|
+
entries = [e for e in entries if isinstance(e, dict) and e.get("batch_id") == batch_id]
|
|
64
|
+
if not entries:
|
|
65
|
+
_die(f"no entry with batch_id {batch_id!r} in {source}")
|
|
66
|
+
|
|
67
|
+
if len(entries) != 1:
|
|
68
|
+
_die(
|
|
69
|
+
f"{source} contains {len(entries)} entries; "
|
|
70
|
+
"use --batch-id to select one"
|
|
71
|
+
)
|
|
72
|
+
if not isinstance(entries[0], dict):
|
|
73
|
+
_die(f"entry in {source} is not a JSON object")
|
|
74
|
+
return entries[0]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _die(msg: str, code: int = 2) -> None:
|
|
78
|
+
print(f"error: {msg}", file=sys.stderr)
|
|
79
|
+
sys.exit(code)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _output(ok: bool, entry: dict, as_json: bool) -> None:
|
|
83
|
+
if as_json:
|
|
84
|
+
print(json.dumps({
|
|
85
|
+
"verified": ok,
|
|
86
|
+
"batch_id": entry.get("batch_id"),
|
|
87
|
+
"merkle_root": entry.get("merkle_root"),
|
|
88
|
+
"ts": entry.get("ts"),
|
|
89
|
+
}))
|
|
90
|
+
elif ok:
|
|
91
|
+
print(
|
|
92
|
+
f"OK: claim is included in batch {entry.get('batch_id')!r} "
|
|
93
|
+
f"(root {entry.get('merkle_root')}, ts {entry.get('ts')})"
|
|
94
|
+
)
|
|
95
|
+
else:
|
|
96
|
+
print(
|
|
97
|
+
"FAIL: inclusion proof does not verify against the registry entry",
|
|
98
|
+
file=sys.stderr,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
103
|
+
p = argparse.ArgumentParser(
|
|
104
|
+
prog="trace-verify",
|
|
105
|
+
description=(
|
|
106
|
+
"Verify a TRACE claim inclusion proof against a registry entry. "
|
|
107
|
+
"Exit code 0 means the signed claim was provably anchored in the "
|
|
108
|
+
"registry at the entry timestamp."
|
|
109
|
+
),
|
|
110
|
+
)
|
|
111
|
+
p.add_argument("--version", action="version", version=f"trace-verify {__version__}")
|
|
112
|
+
p.add_argument("--claim", required=True, metavar="FILE",
|
|
113
|
+
help="signed claim JSON file")
|
|
114
|
+
p.add_argument("--proof", required=True, metavar="FILE",
|
|
115
|
+
help='inclusion proof file: {"leaf_index": int, "audit_path": [...]}')
|
|
116
|
+
|
|
117
|
+
entry_group = p.add_mutually_exclusive_group(required=True)
|
|
118
|
+
entry_group.add_argument("--entry", metavar="FILE",
|
|
119
|
+
help="registry entry file (single JSON object or .ndjson day file)")
|
|
120
|
+
entry_group.add_argument("--entry-url", metavar="URL",
|
|
121
|
+
help="fetch the registry entry from this URL (e.g. a raw GitHub URL)")
|
|
122
|
+
|
|
123
|
+
p.add_argument("--batch-id", default=None, metavar="ID",
|
|
124
|
+
help="select the entry with this batch_id from a multi-line day file")
|
|
125
|
+
p.add_argument("--json", action="store_true", dest="as_json",
|
|
126
|
+
help="emit a machine-readable JSON result instead of plain text")
|
|
127
|
+
return p
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def main(argv: list[str] | None = None) -> int:
|
|
131
|
+
parser = build_parser()
|
|
132
|
+
args = parser.parse_args(argv)
|
|
133
|
+
|
|
134
|
+
claim = _load_json_file(Path(args.claim))
|
|
135
|
+
proof = _load_json_file(Path(args.proof))
|
|
136
|
+
|
|
137
|
+
entry_source = args.entry_url if args.entry_url else args.entry
|
|
138
|
+
entry = _load_entry(entry_source, args.batch_id)
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
if not isinstance(claim, dict):
|
|
142
|
+
raise ValueError("claim is not a JSON object")
|
|
143
|
+
if not isinstance(proof, dict):
|
|
144
|
+
raise ValueError("proof is not a JSON object")
|
|
145
|
+
raw_path = proof.get("audit_path")
|
|
146
|
+
if not isinstance(raw_path, list):
|
|
147
|
+
raise ValueError("proof.audit_path must be a list")
|
|
148
|
+
audit_path = [decode_hash(h) for h in raw_path]
|
|
149
|
+
merkle_root = decode_hash(entry.get("merkle_root"))
|
|
150
|
+
ok = verify_inclusion(
|
|
151
|
+
claim,
|
|
152
|
+
proof.get("leaf_index"),
|
|
153
|
+
audit_path,
|
|
154
|
+
entry.get("leaf_count"),
|
|
155
|
+
merkle_root,
|
|
156
|
+
)
|
|
157
|
+
except ValueError as exc:
|
|
158
|
+
if args.as_json:
|
|
159
|
+
print(json.dumps({"verified": False, "error": str(exc)}))
|
|
160
|
+
else:
|
|
161
|
+
print(f"FAIL: {exc}", file=sys.stderr)
|
|
162
|
+
return 1
|
|
163
|
+
|
|
164
|
+
_output(ok, entry, args.as_json)
|
|
165
|
+
return 0 if ok else 1
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
if __name__ == "__main__":
|
|
169
|
+
sys.exit(main())
|
trace_verify/_verify.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Core TRACE inclusion-proof verification algorithm.
|
|
2
|
+
|
|
3
|
+
Implements the RFC 6962 Merkle tree and RFC 9162 inclusion-proof check
|
|
4
|
+
as specified in docs/anchor-format.md. Standard library only.
|
|
5
|
+
|
|
6
|
+
This module is the single source of truth for the algorithm. The standalone
|
|
7
|
+
script tools/verify_inclusion.py in the repository is an auditable copy of
|
|
8
|
+
this same logic kept in sync for third parties who want to inspect or
|
|
9
|
+
reimplement it without installing the package.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
|
|
18
|
+
LEAF_PREFIX = b"\x00"
|
|
19
|
+
NODE_PREFIX = b"\x01"
|
|
20
|
+
_HASH_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
|
21
|
+
|
|
22
|
+
ANCHOR_FORMAT_VERSION = 1
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def canonical_claim_bytes(claim: dict) -> bytes:
|
|
26
|
+
"""Canonical JSON bytes of a signed claim object (anchor-format.md section 1)."""
|
|
27
|
+
if not isinstance(claim, dict):
|
|
28
|
+
raise ValueError("claim must be a JSON object")
|
|
29
|
+
return json.dumps(
|
|
30
|
+
claim, sort_keys=True, separators=(",", ":"), ensure_ascii=True
|
|
31
|
+
).encode("ascii")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def decode_hash(value: object) -> bytes:
|
|
35
|
+
"""Decode a 'sha256:<64 lowercase hex>' string to 32 raw bytes."""
|
|
36
|
+
if not isinstance(value, str) or not _HASH_RE.match(value):
|
|
37
|
+
raise ValueError(f"malformed hash value: {value!r}")
|
|
38
|
+
return bytes.fromhex(value.split(":", 1)[1])
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def verify_inclusion(
|
|
42
|
+
claim: dict,
|
|
43
|
+
leaf_index: int,
|
|
44
|
+
audit_path: list[bytes],
|
|
45
|
+
leaf_count: int,
|
|
46
|
+
merkle_root: bytes,
|
|
47
|
+
) -> bool:
|
|
48
|
+
"""Return True iff the claim's leaf is proven included under merkle_root.
|
|
49
|
+
|
|
50
|
+
Implements RFC 9162 s2.1.3.2 over an RFC 6962 tree (anchor-format.md s5).
|
|
51
|
+
"""
|
|
52
|
+
if not isinstance(leaf_index, int) or isinstance(leaf_index, bool):
|
|
53
|
+
return False
|
|
54
|
+
if not isinstance(leaf_count, int) or isinstance(leaf_count, bool):
|
|
55
|
+
return False
|
|
56
|
+
if leaf_index < 0 or leaf_count < 1 or leaf_index >= leaf_count:
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
r = hashlib.sha256(LEAF_PREFIX + canonical_claim_bytes(claim)).digest()
|
|
60
|
+
fn = leaf_index
|
|
61
|
+
sn = leaf_count - 1
|
|
62
|
+
|
|
63
|
+
for p in audit_path:
|
|
64
|
+
if sn == 0:
|
|
65
|
+
return False
|
|
66
|
+
if fn & 1 or fn == sn:
|
|
67
|
+
r = hashlib.sha256(NODE_PREFIX + p + r).digest()
|
|
68
|
+
if not fn & 1:
|
|
69
|
+
while fn and not fn & 1:
|
|
70
|
+
fn >>= 1
|
|
71
|
+
sn >>= 1
|
|
72
|
+
else:
|
|
73
|
+
r = hashlib.sha256(NODE_PREFIX + r + p).digest()
|
|
74
|
+
fn >>= 1
|
|
75
|
+
sn >>= 1
|
|
76
|
+
|
|
77
|
+
return sn == 0 and r == merkle_root
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: trace-verify
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Verify TRACE claim inclusion proofs against the public registry
|
|
5
|
+
Project-URL: Homepage, https://github.com/agentrust-io/trace-registry
|
|
6
|
+
Project-URL: Documentation, https://github.com/agentrust-io/trace-registry/blob/main/docs/anchor-format.md
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/agentrust-io/trace-registry/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/agentrust-io/trace-registry/blob/main/CHANGELOG.md
|
|
9
|
+
License: CC-BY-4.0
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ai-governance,audit,inclusion-proof,merkle,trace
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Security :: Cryptography
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
[](LICENSE)
|
|
27
|
+
[](https://github.com/agentrust-io/trace-spec)
|
|
28
|
+
[](https://discord.gg/9JWNpH7E)
|
|
29
|
+
|
|
30
|
+
# TRACE Registry
|
|
31
|
+
|
|
32
|
+
The public accountability layer for TRACE claim anchors. Each entry records the
|
|
33
|
+
Merkle root of a batch of signed TRACE Trust Records, committed to this
|
|
34
|
+
repository as an append-only record. Git's commit history is the
|
|
35
|
+
tamper-evidence layer: any rewrite of a published entry diverges the commit
|
|
36
|
+
hashes that auditors and mirrors have already observed.
|
|
37
|
+
|
|
38
|
+
## Current Registry State
|
|
39
|
+
|
|
40
|
+
The registry currently contains one development entry (registry/2026/06/12.ndjson).
|
|
41
|
+
This is a software-only example anchor with advisory enforcement and a zeroed
|
|
42
|
+
measurement, committed as a launch-day example. It does not represent a production
|
|
43
|
+
Trust Record. The first production entries will be added after Confidential Computing
|
|
44
|
+
Summit launch on June 23, 2026.
|
|
45
|
+
|
|
46
|
+
The anchor construction (canonical claim bytes, leaf hashing, RFC 6962 Merkle
|
|
47
|
+
tree, inclusion proofs) is specified in
|
|
48
|
+
[docs/anchor-format.md](docs/anchor-format.md). A third party can implement a
|
|
49
|
+
verifier from that document alone; the reference tools in [tools/](tools/) are
|
|
50
|
+
one implementation.
|
|
51
|
+
|
|
52
|
+
> **Status.** The format, reference tooling, schema validation, and a first
|
|
53
|
+
> real entry ([registry/2026/06/12.ndjson](registry/2026/06/12.ndjson)) are
|
|
54
|
+
> live. Anchoring is currently manual and low volume; a continuous anchoring
|
|
55
|
+
> cadence and a packaged `trace-verify` CLI on PyPI are planned but not yet
|
|
56
|
+
> operational.
|
|
57
|
+
|
|
58
|
+
## Why this exists
|
|
59
|
+
|
|
60
|
+
Anyone holding a TRACE trust record and its inclusion proof can verify that the
|
|
61
|
+
record was anchored in this registry without trusting the operator who issued
|
|
62
|
+
it, using only this public git history and the verifier below. No single
|
|
63
|
+
operator controls the audit trail.
|
|
64
|
+
|
|
65
|
+
## Registry Format
|
|
66
|
+
|
|
67
|
+
Each daily file in `registry/YYYY/MM/` is newline-delimited JSON, one anchor
|
|
68
|
+
entry per line, validated by CI against
|
|
69
|
+
[schema/registry-entry.schema.json](schema/registry-entry.schema.json):
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{"ts": "2026-06-12T18:09:41Z", "merkle_root": "sha256:9279...bada", "leaf_count": 1, "producer": "cmcp-gateway/0.1.0", "batch_id": "2026-06-12-001"}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Entries are append-only. See [docs/anchor-format.md](docs/anchor-format.md)
|
|
76
|
+
for field semantics.
|
|
77
|
+
|
|
78
|
+
## Verifying a claim
|
|
79
|
+
|
|
80
|
+
You need three things: your signed claim (Trust Record), the inclusion proof
|
|
81
|
+
your producer gave you, and the registry entry for the batch. Then:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
git clone https://github.com/agentrust-io/trace-registry.git
|
|
85
|
+
cd trace-registry
|
|
86
|
+
python tools/verify_inclusion.py \
|
|
87
|
+
--claim samples/example-trust-record.json \
|
|
88
|
+
--proof samples/inclusion-proof.json \
|
|
89
|
+
--entry registry/2026/06/12.ndjson
|
|
90
|
+
# OK: claim is included in batch '2026-06-12-001' (root sha256:9279..., ts 2026-06-12T18:09:41Z)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Exit code 0 means the claim is proven included; 1 means it is not. The
|
|
94
|
+
verifier is a single standard-library Python file, so you can audit it (or
|
|
95
|
+
reimplement it from the spec) rather than trust it. The `samples/` files above
|
|
96
|
+
are a real anchored example you can use to exercise the tooling.
|
|
97
|
+
|
|
98
|
+
Inclusion verification proves the signed claim bytes were anchored at the
|
|
99
|
+
entry's timestamp. Validating the claim's signature against the producer key
|
|
100
|
+
is a separate TRACE step.
|
|
101
|
+
|
|
102
|
+
## Anchoring claims
|
|
103
|
+
|
|
104
|
+
Producers batch signed claims and anchor them with:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
python tools/anchor.py claim1.json claim2.json \
|
|
108
|
+
--producer my-gateway/1.0 --proof-dir proofs/ \
|
|
109
|
+
>> registry/2026/06/12.ndjson
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
This emits the registry entry line and writes one inclusion proof per claim to
|
|
113
|
+
hand back to claim holders.
|
|
114
|
+
|
|
115
|
+
## Canonical Registry
|
|
116
|
+
|
|
117
|
+
This repository exists for independence: TRACE claim anchors can be checked
|
|
118
|
+
without trusting any single operator's infrastructure, and the git history is
|
|
119
|
+
auditable by anyone.
|
|
120
|
+
|
|
121
|
+
## Community
|
|
122
|
+
|
|
123
|
+
Questions, feedback, integration help: [Discord](https://discord.gg/9JWNpH7E).
|
|
124
|
+
|
|
125
|
+
## License
|
|
126
|
+
|
|
127
|
+
Creative Commons Attribution 4.0 International (CC BY 4.0). See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
trace_verify/__init__.py,sha256=04w6eWaQU0uFODv0tHsTihd1DPPpqniq3XSycrl3NqI,371
|
|
2
|
+
trace_verify/__main__.py,sha256=n39tQRL-dC65W9iFoEmX8lDeEq7Ty5xpyyVegstC3Fo,5696
|
|
3
|
+
trace_verify/_verify.py,sha256=2Ejjvl0ItE7mE9Ch6UgwJY7RGkTCI7wgjSpuB0Eal20,2437
|
|
4
|
+
trace_verify-0.1.0.dist-info/METADATA,sha256=h1-zFwOlVWeuX__4-VYbdxz3h9mHMpnFFpvY-RaN7q4,5331
|
|
5
|
+
trace_verify-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
6
|
+
trace_verify-0.1.0.dist-info/entry_points.txt,sha256=w_DKNZPdHbIZ1JY-jW_d1lAY1RJqibg3F6H7CqhJYyA,60
|
|
7
|
+
trace_verify-0.1.0.dist-info/licenses/LICENSE,sha256=PACqbgsD3BOlGQlRVuuvVDMHA8GDKoieQ4oyvvnmRvA,271
|
|
8
|
+
trace_verify-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
Creative Commons Attribution 4.0 International (CC BY 4.0)
|
|
2
|
+
|
|
3
|
+
Copyright 2026 AgentTrust Contributors
|
|
4
|
+
|
|
5
|
+
This work is licensed under the Creative Commons Attribution 4.0 International License.
|
|
6
|
+
To view a copy of this license, visit https://creativecommons.org/licenses/by/4.0/
|