| 1 | """Generate an OSGT-HW-P256-v1 .sealed sample + matching identity JSON. |
| 2 | |
| 3 | Mirrors `tools/gen_hybrid_sample.py`. Self-contained: depends only on |
| 4 | `cryptography` (no `oqs` needed because no PQ). Writes a sample that the |
| 5 | viewer's `decryptSealedHwP256` (and `oversight-rust`'s |
| 6 | `open_sealed_with_provider`) can both consume. |
| 7 | |
| 8 | Usage: |
| 9 | python3 gen_hw_p256_sample.py --out-dir ./out |
| 10 | |
| 11 | Outputs: |
| 12 | out/tutorial-hw-p256.sealed - viewer test fixture |
| 13 | out/tutorial-hw-p256-identity.json - recipient P-256 priv/pub |
| 14 | """ |
| 15 | from __future__ import annotations |
| 16 | |
| 17 | import argparse |
| 18 | import hashlib |
| 19 | import json |
| 20 | import os |
| 21 | import struct |
| 22 | import sys |
| 23 | from pathlib import Path |
| 24 | |
| 25 | from cryptography.hazmat.primitives import hashes, serialization |
| 26 | from cryptography.hazmat.primitives.asymmetric import ec |
| 27 | from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey |
| 28 | from cryptography.hazmat.primitives.kdf.hkdf import HKDF |
| 29 | from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305 |
| 30 | |
| 31 | MAGIC = b"OSGT\x01\x00" |
| 32 | FORMAT_VERSION = 1 |
| 33 | SUITE_HW_P256_V1_ID = 3 |
| 34 | SUITE_HW_P256_V1 = "OSGT-HW-P256-v1" |
| 35 | P256_PUBLIC_KEY_LEN = 65 |
| 36 | |
| 37 | |
| 38 | def _hchacha20(key: bytes, nonce16: bytes) -> bytes: |
| 39 | assert len(key) == 32 and len(nonce16) == 16 |
| 40 | state = bytearray(64) |
| 41 | state[0:4] = b"expa"; state[4:8] = b"nd 3"; state[8:12] = b"2-by"; state[12:16] = b"te k" |
| 42 | state[16:48] = key |
| 43 | state[48:64] = nonce16 |
| 44 | s = list(struct.unpack("<16I", bytes(state))) |
| 45 | |
| 46 | def rotl(v, n): return ((v << n) & 0xFFFFFFFF) | (v >> (32 - n)) |
| 47 | def qr(a, b, c, d): |
| 48 | s[a] = (s[a] + s[b]) & 0xFFFFFFFF; s[d] = rotl(s[d] ^ s[a], 16) |
| 49 | s[c] = (s[c] + s[d]) & 0xFFFFFFFF; s[b] = rotl(s[b] ^ s[c], 12) |
| 50 | s[a] = (s[a] + s[b]) & 0xFFFFFFFF; s[d] = rotl(s[d] ^ s[a], 8) |
| 51 | s[c] = (s[c] + s[d]) & 0xFFFFFFFF; s[b] = rotl(s[b] ^ s[c], 7) |
| 52 | |
| 53 | for _ in range(10): |
| 54 | qr(0, 4, 8, 12); qr(1, 5, 9, 13); qr(2, 6, 10, 14); qr(3, 7, 11, 15) |
| 55 | qr(0, 5, 10, 15); qr(1, 6, 11, 12); qr(2, 7, 8, 13); qr(3, 4, 9, 14) |
| 56 | return struct.pack("<8I", s[0], s[1], s[2], s[3], s[12], s[13], s[14], s[15]) |
| 57 | |
| 58 | |
| 59 | def xchacha20poly1305_encrypt(key: bytes, nonce24: bytes, plaintext: bytes, aad: bytes) -> bytes: |
| 60 | if len(key) != 32 or len(nonce24) != 24: |
| 61 | raise ValueError("xchacha20poly1305 requires 32-byte key and 24-byte nonce") |
| 62 | subkey = _hchacha20(key, nonce24[:16]) |
| 63 | nonce12 = b"\x00\x00\x00\x00" + nonce24[16:24] |
| 64 | return ChaCha20Poly1305(subkey).encrypt(nonce12, plaintext, aad) |
| 65 | |
| 66 | |
| 67 | def canonical_bytes(obj: dict) -> bytes: |
| 68 | return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") |
| 69 | |
| 70 | |
| 71 | def strip_none(obj): |
| 72 | if isinstance(obj, dict): |
| 73 | return {k: strip_none(v) for k, v in obj.items() if v is not None} |
| 74 | if isinstance(obj, list): |
| 75 | return [strip_none(v) for v in obj if v is not None] |
| 76 | return obj |
| 77 | |
| 78 | |
| 79 | def hw_p256_wrap_dek(dek: bytes, recipient_p256_pub_sec1: bytes) -> dict: |
| 80 | if len(recipient_p256_pub_sec1) != P256_PUBLIC_KEY_LEN: |
| 81 | raise ValueError(f"recipient pubkey must be {P256_PUBLIC_KEY_LEN} bytes") |
| 82 | |
| 83 | peer = ec.EllipticCurvePublicKey.from_encoded_point( |
| 84 | ec.SECP256R1(), recipient_p256_pub_sec1 |
| 85 | ) |
| 86 | eph = ec.generate_private_key(ec.SECP256R1()) |
| 87 | shared = eph.exchange(ec.ECDH(), peer) |
| 88 | |
| 89 | kek = HKDF( |
| 90 | algorithm=hashes.SHA256(), length=32, salt=None, |
| 91 | info=b"oversight-hw-p256-v1-dek-wrap", |
| 92 | ).derive(shared) |
| 93 | |
| 94 | nonce = os.urandom(24) |
| 95 | wrapped = xchacha20poly1305_encrypt(kek, nonce, dek, aad=b"oversight-hw-p256-dek") |
| 96 | |
| 97 | eph_pub_bytes = eph.public_key().public_bytes( |
| 98 | encoding=serialization.Encoding.X962, |
| 99 | format=serialization.PublicFormat.UncompressedPoint, |
| 100 | ) |
| 101 | assert len(eph_pub_bytes) == P256_PUBLIC_KEY_LEN |
| 102 | |
| 103 | return { |
| 104 | "suite": SUITE_HW_P256_V1, |
| 105 | "ephemeral_pub": eph_pub_bytes.hex(), |
| 106 | "nonce": nonce.hex(), |
| 107 | "wrapped_dek": wrapped.hex(), |
| 108 | } |
| 109 | |
| 110 | |
| 111 | def main() -> int: |
| 112 | p = argparse.ArgumentParser() |
| 113 | p.add_argument("--out-dir", required=True, type=Path) |
| 114 | p.add_argument("--message", default="hello hardware-keys oversight\n") |
| 115 | args = p.parse_args() |
| 116 | args.out_dir.mkdir(parents=True, exist_ok=True) |
| 117 | |
| 118 | rx_priv = ec.generate_private_key(ec.SECP256R1()) |
| 119 | rx_pub_sec1 = rx_priv.public_key().public_bytes( |
| 120 | encoding=serialization.Encoding.X962, |
| 121 | format=serialization.PublicFormat.UncompressedPoint, |
| 122 | ) |
| 123 | rx_priv_pkcs8 = rx_priv.private_bytes( |
| 124 | encoding=serialization.Encoding.DER, |
| 125 | format=serialization.PrivateFormat.PKCS8, |
| 126 | encryption_algorithm=serialization.NoEncryption(), |
| 127 | ) |
| 128 | rx_priv_scalar = rx_priv.private_numbers().private_value.to_bytes(32, "big") |
| 129 | |
| 130 | issuer = Ed25519PrivateKey.generate() |
| 131 | issuer_pub_bytes = issuer.public_key().public_bytes( |
| 132 | encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw, |
| 133 | ) |
| 134 | |
| 135 | plaintext = args.message.encode("utf-8") |
| 136 | content_hash = hashlib.sha256(plaintext).hexdigest() |
| 137 | dek = os.urandom(32) |
| 138 | |
| 139 | aead_nonce = os.urandom(24) |
| 140 | ciphertext = xchacha20poly1305_encrypt( |
| 141 | dek, aead_nonce, plaintext, aad=content_hash.encode("ascii") |
| 142 | ) |
| 143 | |
| 144 | wrapped_dek = hw_p256_wrap_dek(dek, rx_pub_sec1) |
| 145 | |
| 146 | manifest = { |
| 147 | "suite": SUITE_HW_P256_V1, |
| 148 | "format": "oversight/v1", |
| 149 | "issuer_id": "tutorial-hw-p256@oversightprotocol.dev", |
| 150 | "issuer_ed25519_pub": issuer_pub_bytes.hex(), |
| 151 | "issuer_ml_dsa_pub": "", |
| 152 | "recipient": { |
| 153 | "id": "tutorial@oversightprotocol.dev", |
| 154 | "x25519_pub": "", |
| 155 | "p256_pub": rx_pub_sec1.hex(), |
| 156 | }, |
| 157 | "content_type": "text/plain", |
| 158 | "content_hash": content_hash, |
| 159 | "canonical_content_hash": content_hash, |
| 160 | "l3_policy": {"enabled": False, "mode": "off"}, |
| 161 | "filename": "hello-hw-p256.txt", |
| 162 | "signature_ed25519": "", |
| 163 | "signature_ml_dsa": "", |
| 164 | } |
| 165 | |
| 166 | manifest_for_sign = strip_none(manifest) |
| 167 | manifest_for_sign["signature_ed25519"] = "" |
| 168 | manifest_for_sign["signature_ml_dsa"] = "" |
| 169 | sig_bytes = issuer.sign(canonical_bytes(manifest_for_sign)) |
| 170 | manifest["signature_ed25519"] = sig_bytes.hex() |
| 171 | |
| 172 | manifest_serialized = canonical_bytes(strip_none(manifest)) |
| 173 | wrapped_dek_serialized = canonical_bytes(wrapped_dek) |
| 174 | |
| 175 | container = bytearray() |
| 176 | container.extend(MAGIC) |
| 177 | container.extend(bytes([FORMAT_VERSION, SUITE_HW_P256_V1_ID])) |
| 178 | container.extend(struct.pack(">I", len(manifest_serialized))) |
| 179 | container.extend(manifest_serialized) |
| 180 | container.extend(struct.pack(">I", len(wrapped_dek_serialized))) |
| 181 | container.extend(wrapped_dek_serialized) |
| 182 | container.extend(aead_nonce) |
| 183 | container.extend(struct.pack(">I", len(ciphertext))) |
| 184 | container.extend(ciphertext) |
| 185 | |
| 186 | sealed_path = args.out_dir / "tutorial-hw-p256.sealed" |
| 187 | identity_path = args.out_dir / "tutorial-hw-p256-identity.json" |
| 188 | |
| 189 | sealed_path.write_bytes(bytes(container)) |
| 190 | identity = { |
| 191 | "recipient_id": "tutorial@oversightprotocol.dev", |
| 192 | "p256_priv_scalar": rx_priv_scalar.hex(), |
| 193 | "p256_priv_pkcs8": rx_priv_pkcs8.hex(), |
| 194 | "p256_pub": rx_pub_sec1.hex(), |
| 195 | "ed25519_priv": "public-tutorial-key-does-not-sign", |
| 196 | "ed25519_pub": "public-tutorial-key-does-not-sign", |
| 197 | "_note": "PUBLIC TUTORIAL KEY for OSGT-HW-P256-v1. Demo-only.", |
| 198 | } |
| 199 | identity_path.write_text(json.dumps(identity, indent=2)) |
| 200 | |
| 201 | print(f"[+] wrote {sealed_path} ({sealed_path.stat().st_size} bytes)") |
| 202 | print(f"[+] wrote {identity_path}") |
| 203 | print(f" plaintext SHA-256: {content_hash}") |
| 204 | print(f" suite: {SUITE_HW_P256_V1}") |
| 205 | return 0 |
| 206 | |
| 207 | |
| 208 | if __name__ == "__main__": |
| 209 | sys.exit(main()) |