grundnorm 0.1.0__tar.gz
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.
- grundnorm-0.1.0/.gitignore +96 -0
- grundnorm-0.1.0/LICENSE +21 -0
- grundnorm-0.1.0/PKG-INFO +77 -0
- grundnorm-0.1.0/README.md +57 -0
- grundnorm-0.1.0/examples/resolve.py +27 -0
- grundnorm-0.1.0/grundnorm/__init__.py +15 -0
- grundnorm-0.1.0/grundnorm/client.py +85 -0
- grundnorm-0.1.0/grundnorm/verify.py +64 -0
- grundnorm-0.1.0/pyproject.toml +29 -0
- grundnorm-0.1.0/tests/fixtures/gdpr-art5.json +170 -0
- grundnorm-0.1.0/tests/test_canonicalize.py +28 -0
- grundnorm-0.1.0/tests/test_verify.py +42 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
|
2
|
+
|
|
3
|
+
# dependencies
|
|
4
|
+
/node_modules
|
|
5
|
+
/.pnp
|
|
6
|
+
.pnp.*
|
|
7
|
+
.yarn/*
|
|
8
|
+
!.yarn/patches
|
|
9
|
+
!.yarn/plugins
|
|
10
|
+
!.yarn/releases
|
|
11
|
+
!.yarn/versions
|
|
12
|
+
|
|
13
|
+
# testing
|
|
14
|
+
/coverage
|
|
15
|
+
|
|
16
|
+
# Playwright
|
|
17
|
+
/test-results/
|
|
18
|
+
/playwright-report/
|
|
19
|
+
/blob-report/
|
|
20
|
+
/playwright/.cache/
|
|
21
|
+
/tests/e2e/.auth/
|
|
22
|
+
|
|
23
|
+
# next.js
|
|
24
|
+
/.next/
|
|
25
|
+
/out/
|
|
26
|
+
|
|
27
|
+
# production
|
|
28
|
+
/build
|
|
29
|
+
|
|
30
|
+
# misc
|
|
31
|
+
.DS_Store
|
|
32
|
+
*.pem
|
|
33
|
+
|
|
34
|
+
# debug
|
|
35
|
+
npm-debug.log*
|
|
36
|
+
yarn-debug.log*
|
|
37
|
+
yarn-error.log*
|
|
38
|
+
.pnpm-debug.log*
|
|
39
|
+
|
|
40
|
+
# env files (can opt-in for committing if needed)
|
|
41
|
+
.env*
|
|
42
|
+
|
|
43
|
+
# vercel
|
|
44
|
+
.vercel
|
|
45
|
+
|
|
46
|
+
# typescript
|
|
47
|
+
*.tsbuildinfo
|
|
48
|
+
next-env.d.ts
|
|
49
|
+
|
|
50
|
+
# repomix output files
|
|
51
|
+
repomix-output.*
|
|
52
|
+
tools/mcp-dev/node_modules/
|
|
53
|
+
tools/**/node_modules/
|
|
54
|
+
|
|
55
|
+
# graphify knowledge graph (generated)
|
|
56
|
+
# Ignoramos el grueso (cache/, obsidian/, html, ficheros internos .graphify_*)
|
|
57
|
+
# pero VERSIONAMOS el mapa para que TODAS las instancias lo tengan vía git pull
|
|
58
|
+
# (memoria permanente del repo, sin re-leer todo → ahorra tokens).
|
|
59
|
+
graphify-out/*
|
|
60
|
+
!graphify-out/GRAPH_REPORT.md
|
|
61
|
+
!graphify-out/graph.json
|
|
62
|
+
!graphify-out/manifest.json
|
|
63
|
+
|
|
64
|
+
# MCP server build artifacts
|
|
65
|
+
mcp-server/node_modules/
|
|
66
|
+
mcp-server/dist/
|
|
67
|
+
mcp-server/*.tsbuildinfo
|
|
68
|
+
|
|
69
|
+
# Claude Code worktrees (local-only embedded repos)
|
|
70
|
+
.claude/worktrees/
|
|
71
|
+
|
|
72
|
+
# Documentos sensibles sueltos en raíz (cap tables, decks, etc.)
|
|
73
|
+
Nexus-Legal-Cap-Table-*.pdf
|
|
74
|
+
/*.pdf
|
|
75
|
+
|
|
76
|
+
# Listas de contactos con PII (nombres/emails) — no deben versionarse.
|
|
77
|
+
# El historial previo requiere un scrub aparte (BFG/git filter-repo).
|
|
78
|
+
email-lists/*.csv
|
|
79
|
+
# Artefactos operativos y de runtime
|
|
80
|
+
scripts/*.log
|
|
81
|
+
.gpu_enrich.txt
|
|
82
|
+
# Artefactos de Python
|
|
83
|
+
__pycache__/
|
|
84
|
+
*.pyc
|
|
85
|
+
*.pyo
|
|
86
|
+
# Entornos virtuales de Python (locales)
|
|
87
|
+
.venv-embed/
|
|
88
|
+
.venv/
|
|
89
|
+
|
|
90
|
+
# SDK build artifacts
|
|
91
|
+
sdk/typescript/node_modules/
|
|
92
|
+
sdk/typescript/dist/
|
|
93
|
+
sdk/python/**/__pycache__/
|
|
94
|
+
sdk/python/*.egg-info/
|
|
95
|
+
sdk/python/build/
|
|
96
|
+
sdk/python/dist/
|
grundnorm-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Quantum Nexus Ventures FZCO
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
grundnorm-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: grundnorm
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official SDK for the Grundnorm grounded source-of-law truth layer — resolve a legal norm by open identifier (ELI/ECLI) + date and verify its cryptographic seal locally.
|
|
5
|
+
Project-URL: Homepage, https://grundnorm.nexusquantum.legal
|
|
6
|
+
Project-URL: Repository, https://github.com/djtellado/nexus-grundnorm
|
|
7
|
+
Project-URL: Issues, https://github.com/djtellado/nexus-grundnorm/issues
|
|
8
|
+
Author-email: Quantum Nexus Ventures FZCO <support@nexusquantum.legal>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ecli,ed25519,eli,grundnorm,law,legal,source-of-law,verifiable
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Requires-Dist: cryptography>=41.0
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# grundnorm (Python SDK)
|
|
22
|
+
|
|
23
|
+
Resolve a legal norm by its **open identifier** (ELI/ECLI) and a **date**, and get back its canonical,
|
|
24
|
+
sealed meaning as an **independently verifiable** signed object. The SDK recomputes the content hash and
|
|
25
|
+
verifies every Ed25519 signature locally — **you never have to trust the server**.
|
|
26
|
+
|
|
27
|
+
- Deterministic, zero-LLM read path. Honest `not_found` instead of a guess.
|
|
28
|
+
- Point-in-time: `[validFrom, validUntil)`.
|
|
29
|
+
- Python 3.9+. One dependency: `cryptography`.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install grundnorm
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Use
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from grundnorm import GrundnormClient
|
|
41
|
+
|
|
42
|
+
# Defaults to the public GDPR demonstrator. For a pilot:
|
|
43
|
+
# GrundnormClient(endpoint="https://.../api/grundnorm/resolve", api_key="nlk_...")
|
|
44
|
+
client = GrundnormClient()
|
|
45
|
+
|
|
46
|
+
r = client.resolve(
|
|
47
|
+
id="http://data.europa.eu/eli/reg/2016/679/art_5",
|
|
48
|
+
jurisdiction="EU",
|
|
49
|
+
at="2026-07-07", # omit for "today"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
if r.status == "found":
|
|
53
|
+
print(r.norm["atoms"]) # subject / modality / action / condition / exception / scope + evidence
|
|
54
|
+
print(r.verification["ok"]) # True only if hash recomputes AND all signatures verify
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`resolve()` verifies the seal by default. Skip with `verify=False`, or verify a stored envelope later:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from grundnorm import verify
|
|
61
|
+
v = verify(norm) # {"ok", "hash_ok", "recomputed_hash", "signatures", "quorum"}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## How verification works (no trust required)
|
|
65
|
+
|
|
66
|
+
1. `sha256(canonicalize(norm["canonical"]["content"]))` must equal `norm["seal"]["contentHash"]`,
|
|
67
|
+
where `canonicalize` = JSON with object keys sorted recursively, no whitespace, hashed as UTF-8.
|
|
68
|
+
2. Each `seal.signatures[].signatureHex` is an Ed25519 signature by that signer's `publicKeyHex`
|
|
69
|
+
over the UTF-8 bytes of the `seal.contentHash` hex string.
|
|
70
|
+
|
|
71
|
+
## Errors
|
|
72
|
+
|
|
73
|
+
- Nothing sealed for `(id, date)` → returns `NotFound` (a normal outcome).
|
|
74
|
+
- Bad key, bad input, or server error → raises `GrundnormError` (`.status`, `.code`).
|
|
75
|
+
|
|
76
|
+
> Demonstrator note: the demo corpus (GDPR sample) is signed by demo keys, not institutions, and its
|
|
77
|
+
> accuracy is not yet jurist-graded. See the project's `DEMO-TRUTHFULNESS.md`.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# grundnorm (Python SDK)
|
|
2
|
+
|
|
3
|
+
Resolve a legal norm by its **open identifier** (ELI/ECLI) and a **date**, and get back its canonical,
|
|
4
|
+
sealed meaning as an **independently verifiable** signed object. The SDK recomputes the content hash and
|
|
5
|
+
verifies every Ed25519 signature locally — **you never have to trust the server**.
|
|
6
|
+
|
|
7
|
+
- Deterministic, zero-LLM read path. Honest `not_found` instead of a guess.
|
|
8
|
+
- Point-in-time: `[validFrom, validUntil)`.
|
|
9
|
+
- Python 3.9+. One dependency: `cryptography`.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install grundnorm
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Use
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from grundnorm import GrundnormClient
|
|
21
|
+
|
|
22
|
+
# Defaults to the public GDPR demonstrator. For a pilot:
|
|
23
|
+
# GrundnormClient(endpoint="https://.../api/grundnorm/resolve", api_key="nlk_...")
|
|
24
|
+
client = GrundnormClient()
|
|
25
|
+
|
|
26
|
+
r = client.resolve(
|
|
27
|
+
id="http://data.europa.eu/eli/reg/2016/679/art_5",
|
|
28
|
+
jurisdiction="EU",
|
|
29
|
+
at="2026-07-07", # omit for "today"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
if r.status == "found":
|
|
33
|
+
print(r.norm["atoms"]) # subject / modality / action / condition / exception / scope + evidence
|
|
34
|
+
print(r.verification["ok"]) # True only if hash recomputes AND all signatures verify
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`resolve()` verifies the seal by default. Skip with `verify=False`, or verify a stored envelope later:
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from grundnorm import verify
|
|
41
|
+
v = verify(norm) # {"ok", "hash_ok", "recomputed_hash", "signatures", "quorum"}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## How verification works (no trust required)
|
|
45
|
+
|
|
46
|
+
1. `sha256(canonicalize(norm["canonical"]["content"]))` must equal `norm["seal"]["contentHash"]`,
|
|
47
|
+
where `canonicalize` = JSON with object keys sorted recursively, no whitespace, hashed as UTF-8.
|
|
48
|
+
2. Each `seal.signatures[].signatureHex` is an Ed25519 signature by that signer's `publicKeyHex`
|
|
49
|
+
over the UTF-8 bytes of the `seal.contentHash` hex string.
|
|
50
|
+
|
|
51
|
+
## Errors
|
|
52
|
+
|
|
53
|
+
- Nothing sealed for `(id, date)` → returns `NotFound` (a normal outcome).
|
|
54
|
+
- Bad key, bad input, or server error → raises `GrundnormError` (`.status`, `.code`).
|
|
55
|
+
|
|
56
|
+
> Demonstrator note: the demo corpus (GDPR sample) is signed by demo keys, not institutions, and its
|
|
57
|
+
> accuracy is not yet jurist-graded. See the project's `DEMO-TRUTHFULNESS.md`.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Resolve GDPR Article 5 against the public demonstrator and verify its seal locally.
|
|
2
|
+
|
|
3
|
+
Run: pip install -e . && python examples/resolve.py
|
|
4
|
+
"""
|
|
5
|
+
from grundnorm import GrundnormClient
|
|
6
|
+
|
|
7
|
+
client = GrundnormClient() # defaults to the public GDPR demonstrator
|
|
8
|
+
|
|
9
|
+
r = client.resolve(
|
|
10
|
+
id="http://data.europa.eu/eli/reg/2016/679/art_5",
|
|
11
|
+
jurisdiction="EU",
|
|
12
|
+
at="2026-07-07", # omit for "today"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
if r.status == "not_found":
|
|
16
|
+
print("not_found:", r.note)
|
|
17
|
+
else:
|
|
18
|
+
print(f'{r.norm["articleLabel"]} — {len(r.norm["atoms"])} atoms, provenance {r.norm["provenance"]}')
|
|
19
|
+
for a in r.norm["atoms"]:
|
|
20
|
+
print(f' · [{a["modality"]}/{a["status"]}] {a["subject"]} — {a["action"]}')
|
|
21
|
+
v = r.verification
|
|
22
|
+
print(
|
|
23
|
+
f'\nseal verified locally: {"YES" if v["ok"] else "NO"} '
|
|
24
|
+
f'(hash {"ok" if v["hash_ok"] else "MISMATCH"}, '
|
|
25
|
+
f'{v["quorum"]["valid_signatures"]}/{len(v["signatures"])} signatures, '
|
|
26
|
+
f'sovereign: {v["quorum"]["has_sovereign"]})'
|
|
27
|
+
)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Official Python SDK for the Grundnorm grounded source-of-law truth layer."""
|
|
2
|
+
from .client import DEFAULT_ENDPOINT, DEMO_KEY, GrundnormClient, GrundnormError, NotFound, Resolved
|
|
3
|
+
from .verify import canonicalize, verify
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"GrundnormClient",
|
|
7
|
+
"GrundnormError",
|
|
8
|
+
"Resolved",
|
|
9
|
+
"NotFound",
|
|
10
|
+
"verify",
|
|
11
|
+
"canonicalize",
|
|
12
|
+
"DEFAULT_ENDPOINT",
|
|
13
|
+
"DEMO_KEY",
|
|
14
|
+
]
|
|
15
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Grundnorm Python SDK — resolve a legal norm by open identifier + date, verify the seal locally."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.parse
|
|
7
|
+
import urllib.request
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, Dict, Optional, Union
|
|
10
|
+
|
|
11
|
+
from .verify import verify as _verify
|
|
12
|
+
|
|
13
|
+
DEFAULT_ENDPOINT = "https://ptqvvfnnnfltiwuvkxxw.supabase.co/functions/v1/grundnorm-resolve"
|
|
14
|
+
DEMO_KEY = "nlk_demo_grundnorm_2026"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class GrundnormError(Exception):
|
|
18
|
+
"""Auth / validation / server error from the resolve endpoint."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, message: str, status: int, code: Optional[str] = None):
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.status = status
|
|
23
|
+
self.code = code
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class NotFound:
|
|
28
|
+
"""Nothing sealed for (id, date) — a normal outcome, not an error."""
|
|
29
|
+
|
|
30
|
+
identifier: str
|
|
31
|
+
at: str
|
|
32
|
+
note: str
|
|
33
|
+
status: str = "not_found"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Resolved:
|
|
38
|
+
"""A resolved, sealed norm plus its local verification (None if verify=False)."""
|
|
39
|
+
|
|
40
|
+
norm: Dict[str, Any]
|
|
41
|
+
verification: Optional[Dict[str, Any]]
|
|
42
|
+
status: str = "found"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class GrundnormClient:
|
|
46
|
+
def __init__(self, endpoint: str = DEFAULT_ENDPOINT, api_key: str = DEMO_KEY, timeout: float = 30.0):
|
|
47
|
+
self.endpoint = endpoint
|
|
48
|
+
self.api_key = api_key
|
|
49
|
+
self.timeout = timeout
|
|
50
|
+
|
|
51
|
+
def resolve(
|
|
52
|
+
self,
|
|
53
|
+
id: str,
|
|
54
|
+
jurisdiction: str,
|
|
55
|
+
at: Optional[str] = None,
|
|
56
|
+
domain: Optional[str] = None,
|
|
57
|
+
verify: bool = True,
|
|
58
|
+
) -> Union[Resolved, NotFound]:
|
|
59
|
+
"""Resolve a norm's sealed canonical meaning by identifier + date. Deterministic, zero-LLM.
|
|
60
|
+
|
|
61
|
+
Returns ``Resolved`` on success (with a local seal ``verification`` unless ``verify=False``),
|
|
62
|
+
``NotFound`` when nothing is sealed for (id, date), and raises ``GrundnormError`` otherwise.
|
|
63
|
+
"""
|
|
64
|
+
params = {"id": id, "jurisdiction": jurisdiction}
|
|
65
|
+
if at:
|
|
66
|
+
params["at"] = at
|
|
67
|
+
if domain:
|
|
68
|
+
params["domain"] = domain
|
|
69
|
+
url = self.endpoint + "?" + urllib.parse.urlencode(params)
|
|
70
|
+
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {self.api_key}"})
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as r:
|
|
74
|
+
body = json.loads(r.read().decode("utf-8"))
|
|
75
|
+
except urllib.error.HTTPError as e:
|
|
76
|
+
body = {}
|
|
77
|
+
try:
|
|
78
|
+
body = json.loads(e.read().decode("utf-8"))
|
|
79
|
+
except Exception:
|
|
80
|
+
pass
|
|
81
|
+
if e.code == 404 and body.get("status") == "not_found":
|
|
82
|
+
return NotFound(body.get("identifier"), body.get("at"), body.get("note"))
|
|
83
|
+
raise GrundnormError(body.get("error", f"HTTP {e.code}"), e.code, body.get("code")) from None
|
|
84
|
+
|
|
85
|
+
return Resolved(body, _verify(body) if verify else None)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Canonicalization + Ed25519 verification. Must match the Grundnorm server byte-for-byte."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any, Dict
|
|
7
|
+
|
|
8
|
+
from cryptography.exceptions import InvalidSignature
|
|
9
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def canonicalize(value: Any) -> str:
|
|
13
|
+
"""Deterministic JSON: object keys sorted recursively, no insignificant whitespace.
|
|
14
|
+
|
|
15
|
+
Mirrors the server's canonicalize(): objects -> sorted keys, arrays -> in order, primitives ->
|
|
16
|
+
json.dumps. Output is hashed as UTF-8.
|
|
17
|
+
"""
|
|
18
|
+
if value is None or not isinstance(value, (dict, list)):
|
|
19
|
+
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
20
|
+
if isinstance(value, list):
|
|
21
|
+
return "[" + ",".join(canonicalize(v) for v in value) + "]"
|
|
22
|
+
keys = sorted(value.keys())
|
|
23
|
+
return "{" + ",".join(json.dumps(k, ensure_ascii=False) + ":" + canonicalize(value[k]) for k in keys) + "}"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _sha256_hex(s: str) -> str:
|
|
27
|
+
return hashlib.sha256(s.encode("utf-8")).hexdigest()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _verify_signature(content_hash_hex: str, signature_hex: str, public_key_hex: str) -> bool:
|
|
31
|
+
try:
|
|
32
|
+
pk = Ed25519PublicKey.from_public_bytes(bytes.fromhex(public_key_hex))
|
|
33
|
+
# The message signed is the UTF-8 bytes of the content-hash HEX STRING (not the raw hash bytes).
|
|
34
|
+
pk.verify(bytes.fromhex(signature_hex), content_hash_hex.encode("utf-8"))
|
|
35
|
+
return True
|
|
36
|
+
except (InvalidSignature, ValueError):
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def verify(norm: Dict[str, Any]) -> Dict[str, Any]:
|
|
41
|
+
"""Verify a resolved norm WITHOUT trusting the server.
|
|
42
|
+
|
|
43
|
+
Recomputes the content hash from the exposed canonical preimage and checks every Ed25519
|
|
44
|
+
signature against the public keys in the response. Returns a dict with ``ok`` True iff the hash
|
|
45
|
+
recomputes AND every signature verifies (at least one signature present).
|
|
46
|
+
"""
|
|
47
|
+
seal = norm["seal"]
|
|
48
|
+
recomputed = _sha256_hex(canonicalize(norm["canonical"]["content"]))
|
|
49
|
+
hash_ok = recomputed == seal["contentHash"]
|
|
50
|
+
|
|
51
|
+
signatures = []
|
|
52
|
+
for s in seal["signatures"]:
|
|
53
|
+
ok = _verify_signature(seal["contentHash"], s["signatureHex"], s["publicKeyHex"])
|
|
54
|
+
signatures.append({"signerId": s["signerId"], "signerName": s["signerName"], "role": s["role"], "ok": ok})
|
|
55
|
+
|
|
56
|
+
valid = sum(1 for s in signatures if s["ok"])
|
|
57
|
+
has_sovereign = any(s["ok"] and s["role"] == "N2_sovereign" for s in signatures)
|
|
58
|
+
return {
|
|
59
|
+
"ok": hash_ok and len(signatures) > 0 and all(s["ok"] for s in signatures),
|
|
60
|
+
"hash_ok": hash_ok,
|
|
61
|
+
"recomputed_hash": recomputed,
|
|
62
|
+
"signatures": signatures,
|
|
63
|
+
"quorum": {"valid_signatures": valid, "has_sovereign": has_sovereign},
|
|
64
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "grundnorm"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official SDK for the Grundnorm grounded source-of-law truth layer — resolve a legal norm by open identifier (ELI/ECLI) + date and verify its cryptographic seal locally."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Quantum Nexus Ventures FZCO", email = "support@nexusquantum.legal" }]
|
|
13
|
+
keywords = ["grundnorm", "legal", "law", "eli", "ecli", "verifiable", "ed25519", "source-of-law"]
|
|
14
|
+
dependencies = ["cryptography>=41.0"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Topic :: Software Development :: Libraries",
|
|
20
|
+
"Topic :: Scientific/Engineering",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://grundnorm.nexusquantum.legal"
|
|
25
|
+
Repository = "https://github.com/djtellado/nexus-grundnorm"
|
|
26
|
+
Issues = "https://github.com/djtellado/nexus-grundnorm/issues"
|
|
27
|
+
|
|
28
|
+
[tool.hatch.build.targets.wheel]
|
|
29
|
+
packages = ["grundnorm"]
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
{
|
|
2
|
+
"@context": "https://grundnorm.nexusquantum.legal/ns/v1",
|
|
3
|
+
"@type": "GroundedNorm",
|
|
4
|
+
"identifier": "http://data.europa.eu/eli/reg/2016/679/art_5",
|
|
5
|
+
"scheme": "eli",
|
|
6
|
+
"at": "2026-07-07",
|
|
7
|
+
"jurisdiction": "EU",
|
|
8
|
+
"domain": "legislation",
|
|
9
|
+
"articleLabel": "GDPR — Article 5 (Principles relating to processing of personal data) · DEMO",
|
|
10
|
+
"status": "sealed",
|
|
11
|
+
"legalForce": "official_reference",
|
|
12
|
+
"provenance": "N2_sovereign",
|
|
13
|
+
"validFrom": "2018-05-25",
|
|
14
|
+
"validUntil": null,
|
|
15
|
+
"version": 1,
|
|
16
|
+
"supersedesHash": null,
|
|
17
|
+
"atoms": [
|
|
18
|
+
{
|
|
19
|
+
"id": "a1",
|
|
20
|
+
"statement": "the controller — must process personal data lawfully, fairly and in a transparent manner in relation to the data subject.",
|
|
21
|
+
"subject": "the controller",
|
|
22
|
+
"modality": "obligation",
|
|
23
|
+
"action": "process personal data lawfully, fairly and in a transparent manner in relation to the data subject",
|
|
24
|
+
"condition": null,
|
|
25
|
+
"exception": null,
|
|
26
|
+
"scope": "for the purposes of this Regulation",
|
|
27
|
+
"evidence": "Personal data shall be: processed lawfully, fairly and in a transparent manner in relation to the data subject",
|
|
28
|
+
"inverse": "the controller may process personal data unlawfully, unfairly or opaquely",
|
|
29
|
+
"consensus": {
|
|
30
|
+
"agree": 3,
|
|
31
|
+
"reads": 3,
|
|
32
|
+
"independence": "multi"
|
|
33
|
+
},
|
|
34
|
+
"status": "fixed"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"id": "a2",
|
|
38
|
+
"statement": "the controller — must limit personal data to what is adequate, relevant and necessary for the purposes for which they are processed.",
|
|
39
|
+
"subject": "the controller",
|
|
40
|
+
"modality": "obligation",
|
|
41
|
+
"action": "limit personal data to what is adequate, relevant and necessary for the purposes for which they are processed",
|
|
42
|
+
"condition": null,
|
|
43
|
+
"exception": null,
|
|
44
|
+
"scope": "for the purposes of this Regulation",
|
|
45
|
+
"evidence": "adequate, relevant and limited to what is necessary in relation to the purposes for which they are processed",
|
|
46
|
+
"inverse": "the controller may collect personal data beyond what is necessary for the purpose",
|
|
47
|
+
"consensus": {
|
|
48
|
+
"agree": 3,
|
|
49
|
+
"reads": 3,
|
|
50
|
+
"independence": "multi"
|
|
51
|
+
},
|
|
52
|
+
"status": "fixed"
|
|
53
|
+
}
|
|
54
|
+
],
|
|
55
|
+
"purpose": {
|
|
56
|
+
"aim": "Establish the core principles that govern all processing of personal data",
|
|
57
|
+
"problem": "Unbounded, opaque or excessive processing of personal data",
|
|
58
|
+
"protectedInterest": "The fundamental right to the protection of personal data",
|
|
59
|
+
"protectedParty": "The data subject (natural person)",
|
|
60
|
+
"evidence": "Personal data shall be: processed lawfully, fairly and in a transparent manner",
|
|
61
|
+
"consensus": {
|
|
62
|
+
"agree": 3,
|
|
63
|
+
"reads": 3,
|
|
64
|
+
"independence": "multi"
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
"canonical": {
|
|
68
|
+
"hashAlgorithm": "sha256",
|
|
69
|
+
"canonicalization": "json-sorted-keys-v1",
|
|
70
|
+
"recipe": "1) contentHash: sha256(canonicalize(canonical.content)) must equal seal.contentHash, where canonicalize = JSON with object keys sorted recursively (lexicographic by UTF-16 code unit), no insignificant whitespace, hashed as UTF-8. 2) signatures: each seal.signatures[].signatureHex is an Ed25519 signature by that signer's publicKeyHex over the UTF-8 bytes of the seal.contentHash hex string.",
|
|
71
|
+
"content": {
|
|
72
|
+
"atoms": [
|
|
73
|
+
{
|
|
74
|
+
"id": "a1",
|
|
75
|
+
"accion": "process personal data lawfully, fairly and in a transparent manner in relation to the data subject",
|
|
76
|
+
"status": "fijado",
|
|
77
|
+
"sujeto": "the controller",
|
|
78
|
+
"alcance": "for the purposes of this Regulation",
|
|
79
|
+
"inverse": "the controller may process personal data unlawfully, unfairly or opaquely",
|
|
80
|
+
"evidence": "Personal data shall be: processed lawfully, fairly and in a transparent manner in relation to the data subject",
|
|
81
|
+
"condicion": null,
|
|
82
|
+
"consensus": {
|
|
83
|
+
"agree": 3,
|
|
84
|
+
"reads": 3,
|
|
85
|
+
"independence": "multi"
|
|
86
|
+
},
|
|
87
|
+
"excepcion": null,
|
|
88
|
+
"modalidad": "obligacion",
|
|
89
|
+
"statement": "the controller — must process personal data lawfully, fairly and in a transparent manner in relation to the data subject."
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
"id": "a2",
|
|
93
|
+
"accion": "limit personal data to what is adequate, relevant and necessary for the purposes for which they are processed",
|
|
94
|
+
"status": "fijado",
|
|
95
|
+
"sujeto": "the controller",
|
|
96
|
+
"alcance": "for the purposes of this Regulation",
|
|
97
|
+
"inverse": "the controller may collect personal data beyond what is necessary for the purpose",
|
|
98
|
+
"evidence": "adequate, relevant and limited to what is necessary in relation to the purposes for which they are processed",
|
|
99
|
+
"condicion": null,
|
|
100
|
+
"consensus": {
|
|
101
|
+
"agree": 3,
|
|
102
|
+
"reads": 3,
|
|
103
|
+
"independence": "multi"
|
|
104
|
+
},
|
|
105
|
+
"excepcion": null,
|
|
106
|
+
"modalidad": "obligacion",
|
|
107
|
+
"statement": "the controller — must limit personal data to what is adequate, relevant and necessary for the purposes for which they are processed."
|
|
108
|
+
}
|
|
109
|
+
],
|
|
110
|
+
"domain": "legislation",
|
|
111
|
+
"purpose": {
|
|
112
|
+
"evidence": "Personal data shall be: processed lawfully, fairly and in a transparent manner",
|
|
113
|
+
"problema": "Unbounded, opaque or excessive processing of personal data",
|
|
114
|
+
"consensus": {
|
|
115
|
+
"agree": 3,
|
|
116
|
+
"reads": 3,
|
|
117
|
+
"independence": "multi"
|
|
118
|
+
},
|
|
119
|
+
"finalidad": "Establish the core principles that govern all processing of personal data",
|
|
120
|
+
"bienJuridico": "The fundamental right to the protection of personal data",
|
|
121
|
+
"sujetoProtegido": "The data subject (natural person)"
|
|
122
|
+
},
|
|
123
|
+
"sourceRef": {
|
|
124
|
+
"uri": "http://data.europa.eu/eli/reg/2016/679/art_5",
|
|
125
|
+
"scheme": "eli",
|
|
126
|
+
"validFrom": "2018-05-25",
|
|
127
|
+
"validUntil": null,
|
|
128
|
+
"jurisdiction": "EU"
|
|
129
|
+
},
|
|
130
|
+
"legalForce": "official_reference",
|
|
131
|
+
"articleLabel": "GDPR — Article 5 (Principles relating to processing of personal data) · DEMO",
|
|
132
|
+
"jurisdiction": "EU"
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
"seal": {
|
|
136
|
+
"contentHash": "d5eba87825e051ac1e2634ca197f36d07843aaf99462dbf669c530947f1b4ba9",
|
|
137
|
+
"merkleRoot": "3ef510f2cadc7d6a769f0d83a2576d07d4455f370fdaf56233d1ed00b9d01000",
|
|
138
|
+
"ledgerBlockHash": "1a5348646b242cd206eeac6b6ed68c52b55c326f2e34de4034910ee505f8dd03",
|
|
139
|
+
"blockIndex": 0,
|
|
140
|
+
"anchoredBackend": "mock",
|
|
141
|
+
"anchoredRef": "demo-tx-d5eba87825e0",
|
|
142
|
+
"anchoredUrl": null,
|
|
143
|
+
"signatures": [
|
|
144
|
+
{
|
|
145
|
+
"signerId": "EU-LEG-parliament",
|
|
146
|
+
"signerName": "European Parliament · DEMO",
|
|
147
|
+
"role": "N2_sovereign",
|
|
148
|
+
"publicKeyHex": "87a00dd91e92fc623f08540140dca4f88d0b2048cca34cb612de5738acb8145c",
|
|
149
|
+
"signatureHex": "a2db7c0ddf9def43c63986207a3cd019d6be88235f7469e0a582ea90812a47b671e3c7e5e94b2ea8841c21bda61e8440301a0e0671c46141bf3dab33d25b530e",
|
|
150
|
+
"signedAt": "2026-07-07T10:00:00+00:00"
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
"signerId": "EU-LEG-council",
|
|
154
|
+
"signerName": "Council of the EU · DEMO",
|
|
155
|
+
"role": "N2_sovereign",
|
|
156
|
+
"publicKeyHex": "95960c1b509792befdea49870040d856061f88b2f78c8b40363fff4d9b8d36e2",
|
|
157
|
+
"signatureHex": "5dd05925f2641f6252b8e22c43df8e4a5825bb9bad3d9cf8b19d5a3c8bc009a7b154c4da7f790589640103c20cac89459b55e823ff307113a66edf9dbe950f0b",
|
|
158
|
+
"signedAt": "2026-07-07T10:00:00+00:00"
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
"signerId": "EU-LEG-jurist",
|
|
162
|
+
"signerName": "Legal reviewer · DEMO",
|
|
163
|
+
"role": "N1_expert",
|
|
164
|
+
"publicKeyHex": "25c80d8fa40894c89e31f232cc1dd49bc2bc3b3b1f0e651a0201d569aa6224f4",
|
|
165
|
+
"signatureHex": "ba4c0837aff57a24984b81345a317da420265f0cf56ec4698900f82ebec39cca269e2d464bf4bdcc3468cc076229852b730df773c85491892040fd992acbe70f",
|
|
166
|
+
"signedAt": "2026-07-07T10:00:00+00:00"
|
|
167
|
+
}
|
|
168
|
+
]
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
from grundnorm import canonicalize
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
# These vectors are IDENTICAL to the TypeScript SDK's canonicalize.test.mjs — cross-language parity is
|
|
7
|
+
# the whole point: the same content must produce the same hash on the server, in TS, and in Python.
|
|
8
|
+
class TestCanonicalize(unittest.TestCase):
|
|
9
|
+
def test_sorts_keys_recursively(self):
|
|
10
|
+
self.assertEqual(canonicalize({"b": 1, "a": 2}), '{"a":2,"b":1}')
|
|
11
|
+
self.assertEqual(
|
|
12
|
+
canonicalize({"z": {"y": 1, "x": 2}, "a": [1, {"c": 3, "b": 4}]}),
|
|
13
|
+
'{"a":[1,{"b":4,"c":3}],"z":{"x":2,"y":1}}',
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
def test_array_order(self):
|
|
17
|
+
self.assertEqual(canonicalize([3, 1, 2]), "[3,1,2]")
|
|
18
|
+
|
|
19
|
+
def test_primitives_and_escaping(self):
|
|
20
|
+
self.assertEqual(canonicalize(None), "null")
|
|
21
|
+
self.assertEqual(canonicalize(True), "true")
|
|
22
|
+
self.assertEqual(canonicalize(5), "5")
|
|
23
|
+
self.assertEqual(canonicalize("café"), '"café"') # non-ASCII is NOT escaped
|
|
24
|
+
self.assertEqual(canonicalize('a"b\\c'), '"a\\"b\\\\c"') # quote + backslash escaped
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
if __name__ == "__main__":
|
|
28
|
+
unittest.main()
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import unittest
|
|
5
|
+
|
|
6
|
+
from grundnorm import verify
|
|
7
|
+
|
|
8
|
+
FIXTURE = os.path.join(os.path.dirname(__file__), "fixtures", "gdpr-art5.json")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# Fixture = a real sealed record captured from the live endpoint (GDPR Art. 5, demo signers).
|
|
12
|
+
class TestVerify(unittest.TestCase):
|
|
13
|
+
def setUp(self):
|
|
14
|
+
with open(FIXTURE, encoding="utf-8") as f:
|
|
15
|
+
self.norm = json.load(f)
|
|
16
|
+
|
|
17
|
+
def test_verifies_real_record(self):
|
|
18
|
+
v = verify(self.norm)
|
|
19
|
+
self.assertTrue(v["hash_ok"])
|
|
20
|
+
self.assertTrue(v["ok"])
|
|
21
|
+
self.assertGreaterEqual(len(v["signatures"]), 1)
|
|
22
|
+
self.assertTrue(all(s["ok"] for s in v["signatures"]))
|
|
23
|
+
self.assertEqual(v["recomputed_hash"], self.norm["seal"]["contentHash"])
|
|
24
|
+
|
|
25
|
+
def test_detects_tampered_content(self):
|
|
26
|
+
t = copy.deepcopy(self.norm)
|
|
27
|
+
t["canonical"]["content"]["atoms"][0]["statement"] += " TAMPERED"
|
|
28
|
+
v = verify(t)
|
|
29
|
+
self.assertFalse(v["hash_ok"])
|
|
30
|
+
self.assertFalse(v["ok"])
|
|
31
|
+
|
|
32
|
+
def test_detects_corrupted_signature(self):
|
|
33
|
+
t = copy.deepcopy(self.norm)
|
|
34
|
+
sig = t["seal"]["signatures"][0]["signatureHex"]
|
|
35
|
+
t["seal"]["signatures"][0]["signatureHex"] = ("1" if sig[0] == "0" else "0") + sig[1:]
|
|
36
|
+
v = verify(t)
|
|
37
|
+
self.assertFalse(v["signatures"][0]["ok"])
|
|
38
|
+
self.assertFalse(v["ok"])
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
if __name__ == "__main__":
|
|
42
|
+
unittest.main()
|