sar-envelope 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.
- sar_envelope/__init__.py +52 -0
- sar_envelope/canonicalize.py +20 -0
- sar_envelope/core.py +81 -0
- sar_envelope/keys.py +99 -0
- sar_envelope/py.typed +0 -0
- sar_envelope-0.1.0.dist-info/METADATA +104 -0
- sar_envelope-0.1.0.dist-info/RECORD +9 -0
- sar_envelope-0.1.0.dist-info/WHEEL +5 -0
- sar_envelope-0.1.0.dist-info/top_level.txt +1 -0
sar_envelope/__init__.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""SAR (Settlement Attestation Receipt) portable v0.1 envelope primitives.
|
|
2
|
+
|
|
3
|
+
Implements exactly four capabilities for the six-field portable signed core
|
|
4
|
+
(`task_id_hash, verdict, confidence, reason_code, ts, verifier_kid`):
|
|
5
|
+
|
|
6
|
+
1. JCS (RFC 8785) canonicalization — `canonicalize`.
|
|
7
|
+
2. Deterministic receipt/claim ID derivation — `receipt_id`.
|
|
8
|
+
3. Ed25519 signature-input construction — `signing_input`.
|
|
9
|
+
4. Key discovery against a published `.well-known/sar-keys.json` registry —
|
|
10
|
+
`parse_key_registry`, `resolve_key`, `fetch_key_registry`.
|
|
11
|
+
|
|
12
|
+
This package does not verify signatures, run a trust registry, or implement
|
|
13
|
+
any issuer-specific extension (e.g. wallet-bound counterparty binding). See
|
|
14
|
+
the README for exact scope.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from .canonicalize import canonicalize
|
|
18
|
+
from .core import (
|
|
19
|
+
CORE_FIELDS,
|
|
20
|
+
CoreFieldError,
|
|
21
|
+
core_digest,
|
|
22
|
+
extract_core,
|
|
23
|
+
receipt_id,
|
|
24
|
+
signing_input,
|
|
25
|
+
)
|
|
26
|
+
from .keys import (
|
|
27
|
+
KeyEntry,
|
|
28
|
+
UnknownKeyError,
|
|
29
|
+
UnsupportedAlgorithmError,
|
|
30
|
+
fetch_key_registry,
|
|
31
|
+
parse_key_registry,
|
|
32
|
+
resolve_key,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
__version__ = "0.1.0"
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"canonicalize",
|
|
39
|
+
"CORE_FIELDS",
|
|
40
|
+
"CoreFieldError",
|
|
41
|
+
"core_digest",
|
|
42
|
+
"extract_core",
|
|
43
|
+
"receipt_id",
|
|
44
|
+
"signing_input",
|
|
45
|
+
"KeyEntry",
|
|
46
|
+
"UnknownKeyError",
|
|
47
|
+
"UnsupportedAlgorithmError",
|
|
48
|
+
"fetch_key_registry",
|
|
49
|
+
"parse_key_registry",
|
|
50
|
+
"resolve_key",
|
|
51
|
+
"__version__",
|
|
52
|
+
]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""JCS (RFC 8785) canonicalization.
|
|
2
|
+
|
|
3
|
+
Thin wrapper around the `jcs` package so callers depend on one stable
|
|
4
|
+
entry point (`canonicalize`) regardless of which conformant JCS library
|
|
5
|
+
backs it.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import jcs as _jcs
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def canonicalize(value: Any) -> bytes:
|
|
15
|
+
"""Return the RFC 8785 JSON Canonicalization Scheme encoding of `value`.
|
|
16
|
+
|
|
17
|
+
`value` must be a JSON-compatible structure (dict/list/str/int/float/
|
|
18
|
+
bool/None). Returns UTF-8 bytes.
|
|
19
|
+
"""
|
|
20
|
+
return _jcs.canonicalize(value)
|
sar_envelope/core.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Receipt ID derivation and Ed25519 signature-input construction.
|
|
2
|
+
|
|
3
|
+
The portable SAR v0.1 signed core is exactly six fields:
|
|
4
|
+
|
|
5
|
+
task_id_hash, verdict, confidence, reason_code, ts, verifier_kid
|
|
6
|
+
|
|
7
|
+
`receipt_id` and `signing_input` are two views of the same underlying
|
|
8
|
+
value: `sha256(JCS(core))`. `receipt_id` hex-encodes it with a `sha256:`
|
|
9
|
+
prefix; `signing_input` returns the raw 32 digest bytes that an Ed25519
|
|
10
|
+
private key signs directly (this profile signs the digest, not the raw
|
|
11
|
+
canonical bytes).
|
|
12
|
+
|
|
13
|
+
`counterparty` and any `_ext` metadata are never part of this core. That
|
|
14
|
+
is an issuer-specific extension of Default Settlement's wallet-bound
|
|
15
|
+
profile, not the portable v0.1 contract this package implements.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
from typing import Any, Mapping
|
|
21
|
+
|
|
22
|
+
from .canonicalize import canonicalize
|
|
23
|
+
|
|
24
|
+
CORE_FIELDS = (
|
|
25
|
+
"task_id_hash",
|
|
26
|
+
"verdict",
|
|
27
|
+
"confidence",
|
|
28
|
+
"reason_code",
|
|
29
|
+
"ts",
|
|
30
|
+
"verifier_kid",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class CoreFieldError(ValueError):
|
|
35
|
+
"""Raised when a receipt/core mapping is missing a required field."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def extract_core(receipt: Mapping[str, Any]) -> dict:
|
|
39
|
+
"""Pull exactly the six signed-core fields out of a full receipt object.
|
|
40
|
+
|
|
41
|
+
Raises `CoreFieldError` if any of the six fields is missing. Ignores
|
|
42
|
+
any other field present on `receipt` (e.g. `receipt_id`, `sig`,
|
|
43
|
+
`counterparty`, `_ext`) — those are envelope or unsigned metadata,
|
|
44
|
+
not part of the signed core.
|
|
45
|
+
"""
|
|
46
|
+
missing = [f for f in CORE_FIELDS if f not in receipt]
|
|
47
|
+
if missing:
|
|
48
|
+
raise CoreFieldError(f"receipt is missing required core field(s): {missing}")
|
|
49
|
+
return {k: receipt[k] for k in CORE_FIELDS}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def core_digest(core: Mapping[str, Any]) -> bytes:
|
|
53
|
+
"""Return sha256(JCS(core)) as raw 32 bytes.
|
|
54
|
+
|
|
55
|
+
`core` must contain exactly the six `CORE_FIELDS` (extra keys are
|
|
56
|
+
rejected to avoid silently signing/deriving over the wrong field set —
|
|
57
|
+
use `extract_core` first if you have a full receipt envelope).
|
|
58
|
+
"""
|
|
59
|
+
extra = set(core.keys()) - set(CORE_FIELDS)
|
|
60
|
+
if extra:
|
|
61
|
+
raise CoreFieldError(f"core contains non-core field(s): {sorted(extra)}")
|
|
62
|
+
missing = [f for f in CORE_FIELDS if f not in core]
|
|
63
|
+
if missing:
|
|
64
|
+
raise CoreFieldError(f"core is missing required field(s): {missing}")
|
|
65
|
+
canonical = canonicalize(core)
|
|
66
|
+
return hashlib.sha256(canonical).digest()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def receipt_id(core: Mapping[str, Any]) -> str:
|
|
70
|
+
"""Return the deterministic receipt ID: 'sha256:' + hex(core_digest(core))."""
|
|
71
|
+
return "sha256:" + core_digest(core).hex()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def signing_input(core: Mapping[str, Any]) -> bytes:
|
|
75
|
+
"""Return the exact bytes an Ed25519 key signs for this core.
|
|
76
|
+
|
|
77
|
+
Under the portable SAR v0.1 profile this is the 32-byte sha256 digest
|
|
78
|
+
of the JCS-canonicalized core (the same value `receipt_id` hex-encodes),
|
|
79
|
+
not the raw canonical JSON bytes.
|
|
80
|
+
"""
|
|
81
|
+
return core_digest(core)
|
sar_envelope/keys.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Key discovery against a published `.well-known/sar-keys.json` registry.
|
|
2
|
+
|
|
3
|
+
The registry format is a JWK set: `{"keys": [{"kid", "kty", "crv", "x", ...}]}`.
|
|
4
|
+
This module resolves a `kid` to a usable Ed25519 public key and binds the
|
|
5
|
+
algorithm: only `kty == "OKP"` and `crv == "Ed25519"` are recognized. Any
|
|
6
|
+
other `kty`/`crv` combination is treated as an unsupported algorithm, not
|
|
7
|
+
silently accepted.
|
|
8
|
+
|
|
9
|
+
This module does not implement trust-registry logic (pinning, snapshotting,
|
|
10
|
+
revocation, or refresh policy) — it only parses and resolves. Callers that
|
|
11
|
+
need those properties should build them on top of `parse_key_registry`.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import base64
|
|
16
|
+
import json
|
|
17
|
+
import urllib.request
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Any, Dict, Mapping
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
SUPPORTED_KTY = "OKP"
|
|
23
|
+
SUPPORTED_CRV = "Ed25519"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class UnknownKeyError(KeyError):
|
|
27
|
+
"""Raised when a `kid` is not present in the registry."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class UnsupportedAlgorithmError(ValueError):
|
|
31
|
+
"""Raised when a registry entry's kty/crv is not a recognized Ed25519 binding."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class KeyEntry:
|
|
36
|
+
kid: str
|
|
37
|
+
pubkey: bytes # raw 32-byte Ed25519 public key
|
|
38
|
+
alg: str # always "Ed25519" for entries this module accepts
|
|
39
|
+
raw: Mapping[str, Any] # the original JWK entry, for inspection
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _b64url_decode(value: str) -> bytes:
|
|
43
|
+
pad = (4 - (len(value) % 4)) % 4
|
|
44
|
+
return base64.urlsafe_b64decode(value + ("=" * pad))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def parse_key_registry(document: Mapping[str, Any]) -> Dict[str, KeyEntry]:
|
|
48
|
+
"""Parse a `.well-known/sar-keys.json` document into `{kid: KeyEntry}`.
|
|
49
|
+
|
|
50
|
+
Only entries with `kty == "OKP"` and `crv == "Ed25519"` are included.
|
|
51
|
+
Raises `UnsupportedAlgorithmError` if an entry declares a `kid` but an
|
|
52
|
+
unrecognized kty/crv, since silently skipping it would let a caller
|
|
53
|
+
believe a key doesn't exist rather than that it exists under an
|
|
54
|
+
unsupported algorithm binding.
|
|
55
|
+
"""
|
|
56
|
+
keys = document.get("keys")
|
|
57
|
+
if not isinstance(keys, list):
|
|
58
|
+
raise ValueError("registry document must contain a 'keys' list")
|
|
59
|
+
|
|
60
|
+
result: Dict[str, KeyEntry] = {}
|
|
61
|
+
for entry in keys:
|
|
62
|
+
kid = entry.get("kid")
|
|
63
|
+
if not kid:
|
|
64
|
+
continue
|
|
65
|
+
kty = entry.get("kty")
|
|
66
|
+
crv = entry.get("crv")
|
|
67
|
+
if kty != SUPPORTED_KTY or crv != SUPPORTED_CRV:
|
|
68
|
+
raise UnsupportedAlgorithmError(
|
|
69
|
+
f"key {kid!r} declares kty={kty!r} crv={crv!r}; only "
|
|
70
|
+
f"kty={SUPPORTED_KTY!r} crv={SUPPORTED_CRV!r} is supported"
|
|
71
|
+
)
|
|
72
|
+
x = entry.get("x")
|
|
73
|
+
if not x:
|
|
74
|
+
raise ValueError(f"key {kid!r} is missing required 'x' (public key) field")
|
|
75
|
+
result[kid] = KeyEntry(kid=kid, pubkey=_b64url_decode(x), alg="Ed25519", raw=entry)
|
|
76
|
+
return result
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def resolve_key(registry: Mapping[str, KeyEntry], kid: str) -> KeyEntry:
|
|
80
|
+
"""Resolve `kid` to a `KeyEntry`, raising `UnknownKeyError` if absent."""
|
|
81
|
+
try:
|
|
82
|
+
return registry[kid]
|
|
83
|
+
except KeyError as exc:
|
|
84
|
+
raise UnknownKeyError(f"unknown key id: {kid!r}") from exc
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def fetch_key_registry(
|
|
88
|
+
url: str = "https://defaultverifier.com/.well-known/sar-keys.json",
|
|
89
|
+
timeout: float = 10.0,
|
|
90
|
+
) -> Dict[str, KeyEntry]:
|
|
91
|
+
"""Fetch and parse a `.well-known/sar-keys.json` registry over HTTPS.
|
|
92
|
+
|
|
93
|
+
Convenience wrapper around `parse_key_registry` using only the
|
|
94
|
+
standard library. Callers who already have the document (e.g. from a
|
|
95
|
+
pinned snapshot) should call `parse_key_registry` directly instead.
|
|
96
|
+
"""
|
|
97
|
+
with urllib.request.urlopen(url, timeout=timeout) as response:
|
|
98
|
+
document = json.load(response)
|
|
99
|
+
return parse_key_registry(document)
|
sar_envelope/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sar-envelope
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Portable SAR (Settlement Attestation Receipt) v0.1 envelope primitives: JCS canonicalization, receipt ID derivation, Ed25519 signature-input construction, and .well-known/sar-keys.json key discovery.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://defaultverifier.com
|
|
7
|
+
Project-URL: Fixtures, https://github.com/nutstrut/sar-envelope/tree/main/py/fixtures
|
|
8
|
+
Keywords: sar,jcs,rfc8785,canonicalization,ed25519,receipts
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Security :: Cryptography
|
|
13
|
+
Requires-Python: >=3.8
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
Requires-Dist: jcs>=0.2.1
|
|
16
|
+
Requires-Dist: cryptography>=3.4
|
|
17
|
+
|
|
18
|
+
# sar-envelope (Python)
|
|
19
|
+
|
|
20
|
+
Envelope primitives for the **portable SAR v0.1 signed core** — the
|
|
21
|
+
six-field `task_id_hash, verdict, confidence, reason_code, ts,
|
|
22
|
+
verifier_kid` contract used across SAR (Settlement Attestation Receipt)
|
|
23
|
+
implementations, including independent implementations on non-EVM/non-
|
|
24
|
+
wallet rails.
|
|
25
|
+
|
|
26
|
+
This package implements exactly four things:
|
|
27
|
+
|
|
28
|
+
1. **JCS (RFC 8785) canonicalization** — `canonicalize(value) -> bytes`.
|
|
29
|
+
2. **Deterministic receipt/claim ID derivation** —
|
|
30
|
+
`receipt_id(core) = "sha256:" + hex(sha256(JCS(core)))`.
|
|
31
|
+
3. **Ed25519 signature-input construction** — `signing_input(core)`
|
|
32
|
+
returns the exact 32 bytes an Ed25519 key signs (the sha256 digest of
|
|
33
|
+
the JCS-canonicalized core, not the raw canonical JSON bytes).
|
|
34
|
+
4. **Key discovery** against a published `.well-known/sar-keys.json`
|
|
35
|
+
registry (JWK format) — `parse_key_registry`, `resolve_key`,
|
|
36
|
+
`fetch_key_registry`, including `kid` resolution and algorithm binding
|
|
37
|
+
(only `kty=OKP, crv=Ed25519` is recognized).
|
|
38
|
+
|
|
39
|
+
It does **not** verify signatures, run a trust registry, implement a CLI,
|
|
40
|
+
or encode any issuer-specific extension (e.g. a wallet-bound counterparty
|
|
41
|
+
binding). `counterparty` and `_ext` are never part of the six-field
|
|
42
|
+
signed core this package computes over.
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install sar-envelope
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Example
|
|
51
|
+
|
|
52
|
+
This reproduces fixture `01_valid_portable` from the published conformance
|
|
53
|
+
corpus (see [Fixtures](#fixtures) below) byte-for-byte:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from sar_envelope import extract_core, receipt_id, signing_input
|
|
57
|
+
|
|
58
|
+
receipt = {
|
|
59
|
+
"receipt_version": "0.1",
|
|
60
|
+
"task_id_hash": "sha256:fixture-portable-task-0001",
|
|
61
|
+
"verdict": "PASS",
|
|
62
|
+
"confidence": 1,
|
|
63
|
+
"reason_code": "SPEC_MATCH",
|
|
64
|
+
"ts": "2026-07-23T00:00:00Z",
|
|
65
|
+
"verifier_kid": "fixture-portable-kid-01",
|
|
66
|
+
"sig_alg": "Ed25519",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
core = extract_core(receipt)
|
|
70
|
+
rid = receipt_id(core)
|
|
71
|
+
assert rid == "sha256:be6c760480f4127a842d3321774680531caa4a9acf16684c067086f83a8d8b6f"
|
|
72
|
+
|
|
73
|
+
# The bytes an Ed25519 private key signs for this receipt:
|
|
74
|
+
digest = signing_input(core) # 32 bytes; receipt_id hex-encodes this same value
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Key discovery against the real published registry format:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from sar_envelope import fetch_key_registry, resolve_key
|
|
81
|
+
|
|
82
|
+
registry = fetch_key_registry("https://defaultverifier.com/.well-known/sar-keys.json")
|
|
83
|
+
entry = resolve_key(registry, "sar-prod-ed25519-01")
|
|
84
|
+
entry.pubkey # raw 32-byte Ed25519 public key
|
|
85
|
+
entry.alg # "Ed25519" — only OKP/Ed25519 registry entries are returned
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Fixtures
|
|
89
|
+
|
|
90
|
+
The test suite in this package runs against the canonical Portable SAR
|
|
91
|
+
v0.1 conformance corpus, bundled at `fixtures/portable-sar-fixtures.json`
|
|
92
|
+
and `fixtures/portable-sar-fixture-keys.json`:
|
|
93
|
+
https://github.com/nutstrut/sar-envelope/tree/main/py/fixtures
|
|
94
|
+
|
|
95
|
+
## Scope and versioning
|
|
96
|
+
|
|
97
|
+
Implements **SAR v0.1**, portable six-field signed-core profile only.
|
|
98
|
+
Pre-1.0 (`0.1.0`): the API may change before a 1.0 release. This package
|
|
99
|
+
computes canonical bytes, receipt IDs, and signature input deterministically
|
|
100
|
+
from data you supply — it does not itself authenticate a producer's
|
|
101
|
+
identity, prove human authority over an action, or provide any
|
|
102
|
+
end-to-end verification guarantee. Signature verification and trust-registry
|
|
103
|
+
policy (pinning, revocation, refresh) are the caller's responsibility and
|
|
104
|
+
are out of scope for this package.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
sar_envelope/__init__.py,sha256=_2gHkh1R4Vm60Ba36UChDhPyejuQy3kX5UErfly75Uc,1388
|
|
2
|
+
sar_envelope/canonicalize.py,sha256=aUi7Vp730NI1tB0X2HX5GKvuWkIuRsi6inphOtuAjRc,546
|
|
3
|
+
sar_envelope/core.py,sha256=iDO9W3kPPMTKytr3P_ehf-1lqj4Aa8VFq8QJJkvp2Sc,2892
|
|
4
|
+
sar_envelope/keys.py,sha256=EbAHYm-6tiGItknLxvsU2WptmlK3ogkVMNIlBA2Qbjs,3648
|
|
5
|
+
sar_envelope/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
sar_envelope-0.1.0.dist-info/METADATA,sha256=TY7w02bK6Ul6U3LEf7ppojDopGq9nJjJnYb66ilKOCs,4125
|
|
7
|
+
sar_envelope-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
8
|
+
sar_envelope-0.1.0.dist-info/top_level.txt,sha256=I-c7AYJRq8qDlcsH71lMLrfqGsxMeAHPm54jCAn1TU0,13
|
|
9
|
+
sar_envelope-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sar_envelope
|