Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions examples/enc/incorrect_private_ed25519.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIA0Uz2TA66FhUY6XXFtUm1gitpACP7Fe30g1UbZGubAR
-----END PRIVATE KEY-----
3 changes: 3 additions & 0 deletions examples/enc/incorrect_public_ed25519.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEA5pIucZl12nyISlFbKdWVvG5w66MSfXbshpkh0ehQAIE=
-----END PUBLIC KEY-----
17 changes: 17 additions & 0 deletions examples/mvp/extracted/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"created_at": "2025-10-23T13:09:51.866481+00:00",
"envelope": {
"alg": "AES-256-GCM",
"filename": "mvpEX.stl",
"kdf": "HKDF-SHA256",
"kx": "X25519",
"payload_sha256": "409d11741321d4940018f304a2df598b4c175cb2f2933985ea99f91ff69f2078",
"sender_pub_ed25519": "2NWHb34FFmSUkc77VKhwk2rq9Nj8yhp95xaI9Uvl6GM="
},
"payload": {
"filename": "mvpEX.stl.psenc",
"sha256": "25f2eedf62aeebd95c525de870932e38c76e4c0909f636bebfd4f7338042949e"
},
"policy_id": "nist-800-171",
"v": 1
}
Binary file added examples/mvp/extracted/mvpEX.stl.psenc
Binary file not shown.
3 changes: 3 additions & 0 deletions examples/mvp/incorrect_private_ed25519.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIA0Uz2TA66FhUY6XXFtUm1gitpACP7Fe30g1UbZGubAR
-----END PRIVATE KEY-----
3 changes: 3 additions & 0 deletions examples/mvp/incorrect_public_ed25519.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEA5pIucZl12nyISlFbKdWVvG5w66MSfXbshpkh0ehQAIE=
-----END PUBLIC KEY-----
Binary file added examples/mvp/mvpEX.stl
Binary file not shown.
Binary file added examples/mvp/mvpEX.stl.psenc
Binary file not shown.
Binary file added examples/mvp/mvpEX.stl.psenc.pshieldpkg
Binary file not shown.
3 changes: 3 additions & 0 deletions examples/mvp/sample_private_ed25519.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEICat24dZ+8D7aInN2TpwHIOKYGJUVNVTdBA6zGB6Nfzz
-----END PRIVATE KEY-----
3 changes: 3 additions & 0 deletions examples/mvp/sample_private_x25519.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VuBCIEIGBTwyL4k3HiuW9P6PrHpAYqvVZ+lpfXmPs/k1ZzUCRD
-----END PRIVATE KEY-----
3 changes: 3 additions & 0 deletions examples/mvp/sample_public_ed25519.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEA2NWHb34FFmSUkc77VKhwk2rq9Nj8yhp95xaI9Uvl6GM=
-----END PUBLIC KEY-----
3 changes: 3 additions & 0 deletions examples/mvp/sample_public_x25519.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VuAyEAGJ2TDSnFELT8DVy+WKn8rLefA6Z0X0mS/SBl7TYaXjs=
-----END PUBLIC KEY-----
76 changes: 71 additions & 5 deletions src/printshield/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@
from .core.audit.chain import record_event, verify_log
from .core.crypto.encrypt import encrypt_file, decrypt_file
from .core.transfer.bundle import create_bundle, extract_bundle, BUNDLE_EXT
from .core.provenance.manifest import (
build_provenance, sign_provenance, verify_provenance,
default_manifest_path, save_manifest,
)

@dataclass
class Context:
Expand Down Expand Up @@ -58,6 +62,9 @@ def main_callback(
bundle_app = typer.Typer(help="Create/extract .pshieldpkg job bundles.")
app.add_typer(bundle_app, name="bundle")

prov_app = typer.Typer(help="Provenance sidecar tools for STL.")
app.add_typer(prov_app, name="provenance")

# --- Command stubs ---

@app.command(help="Interactive bootstrap: keys, policy, audit path, endpoints, hardening.")
Expand Down Expand Up @@ -104,7 +111,6 @@ def decrypt(
expected_sender_pub: Path = typer.Option(None, "--expected-sender-pub", "-E", help="Require this Ed25519 public key (PEM)."),
output: Path = typer.Option(None, "--output", "-o", help="Decrypted file path (defaults to strip .psenc)."),
):
from typing import cast
c = cast(Context, ctx.obj)
loaded = load_config(c.config_path)
out_p = decrypt_file(path, private_key, out_path=output, expected_sender_pub_pem=expected_sender_pub)
Expand Down Expand Up @@ -200,9 +206,70 @@ def verify_cmd(

raise typer.Exit(code=0 if ok else 1)

@app.command(help="Create/read provenance manifests; embed/extract when supported.")
def provenance() -> None:
typer.echo("provenance: not implemented yet.")

# ============================ PROVENANCE ================================
@prov_app.command("create", help="Create a (signed) provenance sidecar JSON for an STL file.")
def provenance_create(
ctx: typer.Context,
file: Path = typer.Argument(..., exists=True, file_okay=True, dir_okay=False, readable=True),
signer_key: Path = typer.Option(None, "--signer-key", "-S", help="Ed25519 private key (PEM) to sign the manifest."),
manifest: Path = typer.Option(None, "--manifest", "-m", help="Output manifest path (defaults to <file>.provenance.json)"),
project: str = typer.Option(None, "--project"),
part_id: str = typer.Option(None, "--part-id"),
revision: str = typer.Option(None, "--revision"),
owner: str = typer.Option(None, "--owner"),
slicer_name: str = typer.Option(None, "--slicer-name"),
slicer_version: str = typer.Option(None, "--slicer-version"),
slicer_profile_hash: str = typer.Option(None, "--slicer-profile-hash"),
):
c = cast(Context, ctx.obj)
loaded = load_config(c.config_path)

prov = build_provenance(
file, loaded.config.policy_id,
project=project, part_id=part_id, revision=revision, owner=owner,
slicer_name=slicer_name, slicer_version=slicer_version, slicer_profile_hash=slicer_profile_hash,
)

if signer_key is not None:
prov = sign_provenance(prov, signer_private_key_pem=signer_key)

out_path = manifest or default_manifest_path(file)
save_manifest(prov, out_path)

# Audit
fields = {"file": str(file), "manifest": str(out_path), "signed": signer_key is not None}
record_event(loaded.config.audit.path, "provenance.create", fields)

if c.output_format == "json":
typer.echo(json.dumps({"ok": True, "manifest": str(out_path), "signed": signer_key is not None}, indent=2))
else:
mode = "signed" if signer_key is not None else "unsigned"
typer.echo(f"Provenance ({mode}) → {out_path}")

@prov_app.command("verify", help="Verify a provenance sidecar against an STL file.")
def provenance_verify(
ctx: typer.Context,
file: Path = typer.Argument(..., exists=True, file_okay=True, dir_okay=False, readable=True),
manifest: Path = typer.Option(None, "--manifest", "-m", help="Manifest path (defaults to <file>.provenance.json)"),
expected_signer_pub: Path = typer.Option(None, "--expected-signer-pub", "-E", help="Require this Ed25519 public key (PEM)."),
):
c = cast(Context, ctx.obj)
loaded = load_config(c.config_path)
man_path = manifest or default_manifest_path(file)

res = verify_provenance(file, man_path, expected_signer_pub_pem=expected_signer_pub)
# Audit
record_event(loaded.config.audit.path, "provenance.verify", {"file": str(file), "manifest": str(man_path), "ok": res.ok})

if c.output_format == "json":
typer.echo(json.dumps({"ok": res.ok, "reason": res.reason}, indent=2))
else:
if res.ok:
typer.echo("Provenance OK")
else:
typer.echo(f"Provenance FAIL — {res.reason}")
raise typer.Exit(code=0 if res.ok else 1)

@app.command(help="Securely transfer to printer/server (SFTP/HTTPS/OctoPrint); integrity gate on receive.")
def transfer() -> None:
Expand All @@ -225,7 +292,6 @@ def monitor() -> None:
# ============================ AUDIT-VERIFY ================================
@audit_app.command("verify", help="Verify the tamper-evident audit log chain.")
def audit_verify(ctx: typer.Context) -> None:
from typing import cast
c = cast(Context, ctx.obj)
loaded = load_config(c.config_path)
ok, count, bad = verify_log(loaded.config.audit.path)
Expand Down
164 changes: 164 additions & 0 deletions src/printshield/core/provenance/manifest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
from __future__ import annotations

import json
from base64 import b64encode, b64decode
from dataclasses import dataclass
from datetime import datetime, timezone
from hashlib import sha256
from pathlib import Path
from typing import Optional

from pydantic import BaseModel, Field
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PrivateKey,
Ed25519PublicKey,
)

# ---------- Canonical JSON (JCS-style subset) ----------
def _canon(obj: dict) -> bytes:
return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")

# ---------- Models ----------
class Artifact(BaseModel):
filename: str
sha256: str
size: int = Field(ge=0)
format: str = "stl" # STL-only for now

class SlicerInfo(BaseModel):
name: Optional[str] = None
version: Optional[str] = None
profile_hash: Optional[str] = None

class Provenance(BaseModel):
v: int = 1
created_at: str
policy_id: str
artifact: Artifact
project: Optional[str] = None
part_id: Optional[str] = None
revision: Optional[str] = None
owner: Optional[str] = None
slicer: Optional[SlicerInfo] = None
signer_pub_ed25519: Optional[str] = None # base64 (raw 32 bytes)
signature: Optional[str] = None # base64 (Ed25519 sig over canonicalized object WITHOUT signature)

# ---------- Hash helpers ----------
def file_sha256(path: str | Path) -> str:
p = Path(path)
h = sha256()
with p.open("rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()

# ---------- Build / Sign / Verify ----------
def build_provenance(
file_path: str | Path,
policy_id: str,
*,
project: str | None = None,
part_id: str | None = None,
revision: str | None = None,
owner: str | None = None,
slicer_name: str | None = None,
slicer_version: str | None = None,
slicer_profile_hash: str | None = None,
) -> Provenance:
p = Path(file_path)
prov = Provenance(
created_at=datetime.now(timezone.utc).isoformat(),
policy_id=policy_id,
artifact=Artifact(
filename=p.name,
sha256=file_sha256(p),
size=p.stat().st_size,
format="stl",
),
project=project,
part_id=part_id,
revision=revision,
owner=owner,
slicer=SlicerInfo(name=slicer_name, version=slicer_version, profile_hash=slicer_profile_hash) if any([slicer_name, slicer_version, slicer_profile_hash]) else None,
)
return prov

def _ed25519_pub_bytes(pk: Ed25519PublicKey) -> bytes:
return pk.public_bytes(encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw)

def sign_provenance(prov: Provenance, signer_private_key_pem: str | Path) -> Provenance:
# produce canonical dict WITHOUT signature
obj = prov.model_dump(exclude={"signature"})
# ensure signer_pub is included
sk = serialization.load_pem_private_key(Path(signer_private_key_pem).read_bytes(), password=None)
if not isinstance(sk, Ed25519PrivateKey):
raise TypeError("Signer key must be Ed25519 private key")
signer_pub_raw = _ed25519_pub_bytes(sk.public_key())
obj["signer_pub_ed25519"] = b64encode(signer_pub_raw).decode("ascii")

sig = sk.sign(_canon(obj))
obj["signature"] = b64encode(sig).decode("ascii")
# validate with Pydantic and return
return Provenance.model_validate(obj)

@dataclass
class VerifyResult:
ok: bool
reason: Optional[str] = None

def verify_provenance(
file_path: str | Path,
manifest_path: str | Path,
expected_signer_pub_pem: Optional[str | Path] = None,
) -> VerifyResult:
# read and parse
mp = Path(manifest_path)
try:
raw = json.loads(mp.read_text(encoding="utf-8"))
prov = Provenance.model_validate(raw)
except Exception as e:
return VerifyResult(ok=False, reason=f"Invalid manifest JSON/schema: {e}")

# verify artifact hash
actual = file_sha256(file_path)
if prov.artifact.sha256 != actual:
return VerifyResult(ok=False, reason="Artifact hash mismatch")

# if there is a signature, verify it
has_sig = prov.signature is not None and prov.signer_pub_ed25519 is not None
if has_sig:
signer_pub_raw = b64decode(prov.signer_pub_ed25519)
pk = Ed25519PublicKey.from_public_bytes(signer_pub_raw)
# rebuild canonical object without signature
obj_wo_sig = prov.model_dump(exclude={"signature"})
sig = b64decode(prov.signature)
try:
pk.verify(sig, _canon(obj_wo_sig))
except Exception as e:
return VerifyResult(ok=False, reason=f"Signature invalid: {e}")

# optional pinning
if expected_signer_pub_pem is not None:
exp_pk = serialization.load_pem_public_key(Path(expected_signer_pub_pem).read_bytes())
if not isinstance(exp_pk, Ed25519PublicKey):
return VerifyResult(ok=False, reason="expected_signer_pub must be Ed25519 public key")
if _ed25519_pub_bytes(exp_pk) != signer_pub_raw:
return VerifyResult(ok=False, reason="Signer pubkey does not match expected")

return VerifyResult(ok=True)

# unsigned manifest -> succeed only if user didn’t require pinning
if expected_signer_pub_pem is not None:
return VerifyResult(ok=False, reason="Manifest is unsigned but an expected signer was provided")
return VerifyResult(ok=True)

# ---------- I/O ----------
def default_manifest_path(file_path: str | Path) -> Path:
p = Path(file_path)
return p.with_suffix(p.suffix + ".provenance.json")

def save_manifest(prov: Provenance, path: str | Path) -> Path:
pp = Path(path)
pp.write_text(json.dumps(prov.model_dump(), indent=2), encoding="utf-8")
return pp
Loading