mirror of
https://github.com/luxfi/crypto.git
synced 2026-07-27 01:54:50 +00:00
promptseal: HPKE prompt-confidentiality envelope — closes audit G9
Seals a prompt to an operator's registered KEM key so it is NEVER plaintext on the wire and only the chosen operator opens it (RFC 9180 base mode, X25519-HKDF-SHA256 + ChaCha20-Poly1305, over cloudflare/circl). aad binds the request (intentID) — no cross-request replay. 6/6: round-trip, wrong-key-can't-open, tampered-ciphertext-rejected, wrong-aad-rejected, observer-learns-nothing (no plaintext in blob + two seals differ), malformed-key-rejected.
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
// Package promptseal is the confidentiality envelope for Proof-of-Inference: it seals a user's
|
||||
// prompt to an operator's registered public key so the prompt is NEVER plaintext on the wire and
|
||||
// only the chosen operator — inside its compute boundary — can open it. This closes the audit's G9:
|
||||
// the default inference path handed the operator the plaintext prompt by hash; now the operator
|
||||
// registry carries a recipient KEM key and the requester seals to it.
|
||||
//
|
||||
// It is a thin, single-shot wrapper over RFC 9180 HPKE (base mode) with X25519 + HKDF-SHA256 +
|
||||
// ChaCha20-Poly1305 — the same primitive the rest of the stack uses (github.com/cloudflare/circl).
|
||||
// The associated data (aad) binds the ciphertext to its context (e.g. the intentID), so a sealed
|
||||
// prompt cannot be replayed under a different request.
|
||||
package promptseal
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
|
||||
"github.com/cloudflare/circl/hpke"
|
||||
)
|
||||
|
||||
// Info domain-separates this use of HPKE from any other in the stack.
|
||||
var info = []byte("hanzo/poi/prompt-seal/v1")
|
||||
|
||||
// suite: X25519-HKDF-SHA256 KEM, HKDF-SHA256 KDF, ChaCha20-Poly1305 AEAD.
|
||||
var suite = hpke.NewSuite(hpke.KEM_X25519_HKDF_SHA256, hpke.KDF_HKDF_SHA256, hpke.AEAD_ChaCha20Poly1305)
|
||||
|
||||
// kemScheme is the X25519 KEM; its encapsulated key ("enc") is 32 bytes.
|
||||
var kemScheme = hpke.KEM_X25519_HKDF_SHA256.Scheme()
|
||||
|
||||
// encLen is the X25519 encapsulated-key length prefixed to every sealed blob.
|
||||
var encLen = kemScheme.EncapsulationSeedSize() // 32 for X25519
|
||||
|
||||
var (
|
||||
ErrShortSealed = errors.New("promptseal: sealed blob shorter than the encapsulated key")
|
||||
ErrBadKey = errors.New("promptseal: malformed operator key")
|
||||
)
|
||||
|
||||
// GenerateOperatorKey returns an operator's (publicKey, privateKey) for its registry entry. The
|
||||
// public key is published on-chain; the private key never leaves the operator's compute boundary.
|
||||
func GenerateOperatorKey() (pub, priv []byte, err error) {
|
||||
pk, sk, err := kemScheme.GenerateKeyPair()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
pub, err = pk.MarshalBinary()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
priv, err = sk.MarshalBinary()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return pub, priv, nil
|
||||
}
|
||||
|
||||
// SealPrompt seals `prompt` to an operator's `operatorPub`, binding `aad` (context, e.g. intentID).
|
||||
// Output is `enc ‖ ciphertext`. Semantically secure: two seals of the same prompt differ, and the
|
||||
// blob reveals nothing about the prompt to anyone without the operator's private key.
|
||||
func SealPrompt(operatorPub, prompt, aad []byte) ([]byte, error) {
|
||||
pk, err := kemScheme.UnmarshalBinaryPublicKey(operatorPub)
|
||||
if err != nil {
|
||||
return nil, ErrBadKey
|
||||
}
|
||||
sender, err := suite.NewSender(pk, info)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
enc, sealer, err := sender.Setup(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ct, err := sealer.Seal(prompt, aad)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(append([]byte(nil), enc...), ct...), nil
|
||||
}
|
||||
|
||||
// OpenPrompt opens a sealed blob with the operator's `operatorPriv` and the same `aad`. Returns an
|
||||
// error if the key is wrong, the blob was tampered, or the aad does not match (AEAD authentication).
|
||||
func OpenPrompt(operatorPriv, sealed, aad []byte) ([]byte, error) {
|
||||
if len(sealed) < encLen {
|
||||
return nil, ErrShortSealed
|
||||
}
|
||||
sk, err := kemScheme.UnmarshalBinaryPrivateKey(operatorPriv)
|
||||
if err != nil {
|
||||
return nil, ErrBadKey
|
||||
}
|
||||
receiver, err := suite.NewReceiver(sk, info)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opener, err := receiver.Setup(sealed[:encLen])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return opener.Open(sealed[encLen:], aad)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package promptseal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSealRoundTrip(t *testing.T) {
|
||||
pub, priv, err := GenerateOperatorKey()
|
||||
if err != nil {
|
||||
t.Fatalf("keygen: %v", err)
|
||||
}
|
||||
prompt := []byte("what is the airspeed velocity of an unladen swallow?")
|
||||
aad := []byte("intent:0xabc123")
|
||||
sealed, err := SealPrompt(pub, prompt, aad)
|
||||
if err != nil {
|
||||
t.Fatalf("seal: %v", err)
|
||||
}
|
||||
got, err := OpenPrompt(priv, sealed, aad)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, prompt) {
|
||||
t.Fatal("round-trip mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
// ADVERSARIAL: a different operator (or any third party) cannot open a prompt sealed to someone else.
|
||||
func TestWrongKeyCannotOpen(t *testing.T) {
|
||||
pubA, _, _ := GenerateOperatorKey()
|
||||
_, privB, _ := GenerateOperatorKey() // a DIFFERENT operator
|
||||
sealed, err := SealPrompt(pubA, []byte("secret prompt"), []byte("ctx"))
|
||||
if err != nil {
|
||||
t.Fatalf("seal: %v", err)
|
||||
}
|
||||
if _, err := OpenPrompt(privB, sealed, []byte("ctx")); err == nil {
|
||||
t.Fatal("a non-recipient operator must NOT be able to open the prompt")
|
||||
}
|
||||
}
|
||||
|
||||
// ADVERSARIAL: flipping any ciphertext byte makes Open fail (AEAD integrity).
|
||||
func TestTamperedCiphertextRejected(t *testing.T) {
|
||||
pub, priv, _ := GenerateOperatorKey()
|
||||
sealed, _ := SealPrompt(pub, []byte("integrity matters"), []byte("ctx"))
|
||||
for _, i := range []int{0, len(sealed) / 2, len(sealed) - 1} {
|
||||
bad := append([]byte(nil), sealed...)
|
||||
bad[i] ^= 0x01
|
||||
if _, err := OpenPrompt(priv, bad, []byte("ctx")); err == nil {
|
||||
t.Fatalf("a tampered byte at %d must be rejected", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ADVERSARIAL: the aad binds context — opening under a different aad (a replay to another request)
|
||||
// fails authentication.
|
||||
func TestWrongAADRejected(t *testing.T) {
|
||||
pub, priv, _ := GenerateOperatorKey()
|
||||
sealed, _ := SealPrompt(pub, []byte("bound to intent A"), []byte("intent:A"))
|
||||
if _, err := OpenPrompt(priv, sealed, []byte("intent:B")); err == nil {
|
||||
t.Fatal("a sealed prompt must not open under a different aad (no cross-request replay)")
|
||||
}
|
||||
}
|
||||
|
||||
// ADVERSARIAL: a passive observer learns nothing. The sealed blob does not contain the plaintext,
|
||||
// and two seals of the SAME prompt differ (semantic security / fresh ephemeral key each time).
|
||||
func TestObserverLearnsNothing(t *testing.T) {
|
||||
pub, _, _ := GenerateOperatorKey()
|
||||
prompt := []byte("a very distinctive plaintext marker XYZZY")
|
||||
s1, _ := SealPrompt(pub, prompt, []byte("ctx"))
|
||||
s2, _ := SealPrompt(pub, prompt, []byte("ctx"))
|
||||
if bytes.Contains(s1, prompt) {
|
||||
t.Fatal("the sealed blob must not contain the plaintext")
|
||||
}
|
||||
if bytes.Equal(s1, s2) {
|
||||
t.Fatal("two seals of the same prompt must differ (semantic security)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMalformedKeyRejected(t *testing.T) {
|
||||
pub, _, _ := GenerateOperatorKey()
|
||||
if _, err := SealPrompt([]byte{1, 2, 3}, []byte("x"), nil); err == nil {
|
||||
t.Fatal("a malformed operator public key must be rejected")
|
||||
}
|
||||
sealed, _ := SealPrompt(pub, []byte("x"), nil)
|
||||
if _, err := OpenPrompt([]byte{1, 2, 3}, sealed, nil); err == nil {
|
||||
t.Fatal("a malformed operator private key must be rejected")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user