Skip to content
Draft
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
85 changes: 85 additions & 0 deletions capabilities/networking/confidentialhttp/encryption.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package confidentialhttp

import (
"crypto/aes"
"crypto/cipher"
"crypto/sha256"
"fmt"
"io"

"golang.org/x/crypto/hkdf"
)

const (
// EncryptionKeySecretName is the VaultDON secret name used for AES-GCM encryption
// of confidential HTTP responses.
EncryptionKeySecretName = "san_marino_aes_gcm_encryption_key"

// hkdfInfo is the context string for HKDF key derivation.
hkdfInfo = "confidential-http-encryption-key-v1"

aesKeyLen = 32 // AES-256
gcmNonceLen = 12
gcmTagLen = 16
)

// DeriveEncryptionKey applies HKDF-SHA256 to a user passphrase and returns a
// 32-byte AES-256 key. The derivation uses no salt and
// "confidential-http-encryption-key-v1" as the info parameter.
func DeriveEncryptionKey(passphrase string) ([]byte, error) {
r := hkdf.New(sha256.New, []byte(passphrase), nil, []byte(hkdfInfo))
key := make([]byte, aesKeyLen)
if _, err := io.ReadFull(r, key); err != nil {
return nil, fmt.Errorf("hkdf expand: %w", err)
}
return key, nil
}

// NewRequestForEncryptedResponse wraps an HTTPRequest with EncryptOutput=true and the AES
// key SecretIdentifier auto-injected. Owner is the workflow owner address.
func NewRequestForEncryptedResponse(req *HTTPRequest, owner string) *ConfidentialHTTPRequest {
req.EncryptOutput = true
return &ConfidentialHTTPRequest{
Request: req,
VaultDonSecrets: []*SecretIdentifier{
{
Key: EncryptionKeySecretName,
Owner: &owner,
},
},
}
}

// DecryptResponseBody decrypts an AES-GCM encrypted response body using the
// same passphrase that was used to store the encryption key.
//
// Wire format: [12-byte nonce][ciphertext+16-byte GCM tag]
func DecryptResponseBody(ciphertext []byte, passphrase string) ([]byte, error) {
if len(ciphertext) < gcmNonceLen+gcmTagLen {
return nil, fmt.Errorf("ciphertext too short: need at least %d bytes, got %d", gcmNonceLen+gcmTagLen, len(ciphertext))
}

key, err := DeriveEncryptionKey(passphrase)
if err != nil {
return nil, err
}

block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("aes.NewCipher: %w", err)
}

gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("cipher.NewGCM: %w", err)
}

nonce := ciphertext[:gcmNonceLen]
sealed := ciphertext[gcmNonceLen:]
plaintext, err := gcm.Open(nil, nonce, sealed, nil)
if err != nil {
return nil, fmt.Errorf("gcm decrypt: %w", err)
}

return plaintext, nil
}
139 changes: 139 additions & 0 deletions capabilities/networking/confidentialhttp/encryption_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package confidentialhttp

import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/hex"
"testing"
)

// Shared cross-language test vector. The TypeScript SDK must produce the same output.
const (
testPassphrase = "test-passphrase-for-ci"
testExpectedHex = "521af99325c07c9bd0d224c5bf3ca25666c68b5fbb7fa7884019b4f60a8e6eb5"
)

func TestDeriveEncryptionKey_Deterministic(t *testing.T) {
k1, err := DeriveEncryptionKey("my-passphrase")
if err != nil {
t.Fatal(err)
}
k2, err := DeriveEncryptionKey("my-passphrase")
if err != nil {
t.Fatal(err)
}
if hex.EncodeToString(k1) != hex.EncodeToString(k2) {
t.Fatal("same passphrase produced different keys")
}
}

func TestDeriveEncryptionKey_DifferentPassphrases(t *testing.T) {
k1, err := DeriveEncryptionKey("passphrase-a")
if err != nil {
t.Fatal(err)
}
k2, err := DeriveEncryptionKey("passphrase-b")
if err != nil {
t.Fatal(err)
}
if hex.EncodeToString(k1) == hex.EncodeToString(k2) {
t.Fatal("different passphrases produced the same key")
}
}

func TestDeriveEncryptionKey_CrossLanguageVector(t *testing.T) {
key, err := DeriveEncryptionKey(testPassphrase)
if err != nil {
t.Fatal(err)
}
got := hex.EncodeToString(key)
if got != testExpectedHex {
t.Fatalf("HKDF vector mismatch:\n got: %s\n want: %s", got, testExpectedHex)
}
}

func TestNewRequestForEncryptedResponse(t *testing.T) {
req := &HTTPRequest{
Url: "https://example.com",
Method: "GET",
}
owner := "0xDeaDBeeF"
cr := NewRequestForEncryptedResponse(req, owner)

if !cr.Request.EncryptOutput {
t.Fatal("EncryptOutput should be true")
}
if len(cr.VaultDonSecrets) != 1 {
t.Fatalf("expected 1 secret identifier, got %d", len(cr.VaultDonSecrets))
}
sid := cr.VaultDonSecrets[0]
if sid.Key != EncryptionKeySecretName {
t.Fatalf("secret key = %q, want %q", sid.Key, EncryptionKeySecretName)
}
if sid.GetOwner() != owner {
t.Fatalf("secret owner = %q, want %q", sid.GetOwner(), owner)
}
}

func TestDecryptResponseBody_RoundTrip(t *testing.T) {
passphrase := "round-trip-test"
plaintext := []byte("hello confidential http")

key, err := DeriveEncryptionKey(passphrase)
if err != nil {
t.Fatal(err)
}

block, err := aes.NewCipher(key)
if err != nil {
t.Fatal(err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
t.Fatal(err)
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
t.Fatal(err)
}
sealed := gcm.Seal(nonce, nonce, plaintext, nil)

got, err := DecryptResponseBody(sealed, passphrase)
if err != nil {
t.Fatal(err)
}
if string(got) != string(plaintext) {
t.Fatalf("decrypted = %q, want %q", got, plaintext)
}
}

func TestDecryptResponseBody_TooShort(t *testing.T) {
_, err := DecryptResponseBody(make([]byte, 10), "any")
if err == nil {
t.Fatal("expected error for short ciphertext")
}
}

func TestDecryptResponseBody_WrongPassphrase(t *testing.T) {
passphrase := "correct-passphrase"
plaintext := []byte("secret data")

key, err := DeriveEncryptionKey(passphrase)
if err != nil {
t.Fatal(err)
}

block, _ := aes.NewCipher(key)
gcm, _ := cipher.NewGCM(block)
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
t.Fatal(err)
}
sealed := gcm.Seal(nonce, nonce, plaintext, nil)

_, err = DecryptResponseBody(sealed, "wrong-passphrase")
if err == nil {
t.Fatal("expected error when decrypting with wrong passphrase")
}
}
2 changes: 2 additions & 0 deletions capabilities/networking/confidentialhttp/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ require (
github.com/smartcontractkit/cre-sdk-go v1.0.1-0.20251111122439-00032d582c18
)

require golang.org/x/crypto v0.48.0 // indirect

require (
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
Expand Down
2 changes: 2 additions & 0 deletions capabilities/networking/confidentialhttp/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ github.com/smartcontractkit/cre-sdk-go v1.0.1-0.20251111122439-00032d582c18 h1:x
github.com/smartcontractkit/cre-sdk-go v1.0.1-0.20251111122439-00032d582c18/go.mod h1:sgiRyHUiPcxp1e/EMnaJ+ddMFL4MbE3UMZ2MORAAS9U=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
Expand Down
Loading