Files
cocos/pkg/crypto/decrypt.go
T
Sammy Kerata Oina 13f7e97d82
CI / checkproto (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (agent) (push) Has been cancelled
CI / test (cli) (push) Has been cancelled
CI / test (cmd) (push) Has been cancelled
CI / test (internal) (push) Has been cancelled
CI / test (manager, true) (push) Has been cancelled
CI / test (pkg) (push) Has been cancelled
CI / upload-coverage (push) Has been cancelled
NOISSUE - Add FetchKbsToken RPC support, update protobuf generation, and include additional binaries in CI workflow. (#610)
* feat: add FetchKbsToken RPC support, update protobuf generation, and include additional binaries in CI workflow.

Signed-off-by: Sammy Oina <sammyoina@gmail.com>

* chore: update protoc version and add GetKbsToken mock method with updated kbsHTTPGet signature

Signed-off-by: Sammy Oina <sammyoina@gmail.com>

* test: inject mock attestation client into agentService for resource and KBS tests

Signed-off-by: Sammy Oina <sammyoina@gmail.com>

* test: update key derivation in tests to use Concat KDF instead of HKDF

Signed-off-by: Sammy Oina <sammyoina@gmail.com>

---------

Signed-off-by: Sammy Oina <sammyoina@gmail.com>
2026-07-06 14:26:15 +02:00

354 lines
10 KiB
Go

// Copyright (c) Ultraviolet
// SPDX-License-Identifier: Apache-2.0
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/ecdh"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"github.com/absmach/magistrala/pkg/errors"
)
var (
// ErrDecryptionFailed indicates a decryption operation failed.
ErrDecryptionFailed = errors.New("decryption failed")
// ErrInvalidKey indicates the provided key is invalid.
ErrInvalidKey = errors.New("invalid decryption key")
// ErrInvalidCiphertext indicates the ciphertext is invalid or corrupted.
ErrInvalidCiphertext = errors.New("invalid ciphertext")
// ErrInvalidFormat indicates the encrypted resource format is invalid.
ErrInvalidFormat = errors.New("invalid encrypted resource format")
)
// EncryptedResource represents an encrypted resource from KBS.
// This matches the format used by Confidential Containers KBS.
type EncryptedResource struct {
// Ciphertext is the encrypted data.
Ciphertext []byte `json:"ciphertext"`
// EncryptedKey is the wrapped encryption key.
EncryptedKey []byte `json:"encrypted_key"`
// IV is the initialization vector for AES-GCM.
IV []byte `json:"iv"`
// Tag is the authentication tag for AES-GCM.
Tag []byte `json:"tag"`
// AAD is the additional authenticated data.
AAD []byte `json:"aad,omitempty"`
// EPK is the ephemeral public key for ECDH key derivation.
EPK *EphemeralPublicKey `json:"epk,omitempty"`
}
// EphemeralPublicKey represents an ephemeral EC P-256 public key.
type EphemeralPublicKey struct {
// Curve is the elliptic curve (should be "P-256").
Curve string `json:"crv"`
// X is the X coordinate of the public key.
X string `json:"x"`
// Y is the Y coordinate of the public key.
Y string `json:"y"`
}
// DecryptAESGCM decrypts data using AES-GCM with the provided key.
// This is used when the decryption key is provided directly (not wrapped).
func DecryptAESGCM(ciphertext, key, iv, tag, aad []byte) ([]byte, error) {
if len(key) != 16 && len(key) != 24 && len(key) != 32 {
return nil, errors.Wrap(ErrInvalidKey, errors.New("key must be 16, 24, or 32 bytes"))
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, errors.Wrap(ErrDecryptionFailed, err)
}
aesgcm, err := cipher.NewGCM(block)
if err != nil {
return nil, errors.Wrap(ErrDecryptionFailed, err)
}
// Combine ciphertext and tag for GCM
combined := append(ciphertext, tag...)
plaintext, err := aesgcm.Open(nil, iv, combined, aad)
if err != nil {
return nil, errors.Wrap(ErrDecryptionFailed, err)
}
return plaintext, nil
}
// DecryptWithWrappedKey decrypts data using a wrapped key and ECDH key derivation.
// This matches the KBS encryption format with ephemeral key exchange.
func DecryptWithWrappedKey(encryptedResource EncryptedResource, privateKey *ecdh.PrivateKey) ([]byte, error) {
if encryptedResource.EPK == nil {
return nil, errors.Wrap(ErrInvalidFormat, errors.New("ephemeral public key is required"))
}
// Decode ephemeral public key coordinates
xBytes, err := base64.RawURLEncoding.DecodeString(encryptedResource.EPK.X)
if err != nil {
return nil, errors.Wrap(ErrInvalidFormat, err)
}
yBytes, err := base64.RawURLEncoding.DecodeString(encryptedResource.EPK.Y)
if err != nil {
return nil, errors.Wrap(ErrInvalidFormat, err)
}
// Reconstruct ephemeral public key (uncompressed format: 0x04 || X || Y)
epkBytes := make([]byte, 1+len(xBytes)+len(yBytes))
epkBytes[0] = 0x04
copy(epkBytes[1:], xBytes)
copy(epkBytes[1+len(xBytes):], yBytes)
curve := ecdh.P256()
epk, err := curve.NewPublicKey(epkBytes)
if err != nil {
return nil, errors.Wrap(ErrInvalidFormat, err)
}
// Perform ECDH to derive shared secret
sharedSecret, err := privateKey.ECDH(epk)
if err != nil {
return nil, errors.Wrap(ErrDecryptionFailed, err)
}
// Derive KEK (Key Encryption Key) using Concat KDF (NIST SP 800-56A)
algStr := "ECDH-ES+A256KW"
otherInfo := make([]byte, 0, 4+len(algStr)+4+4+4)
algLen := uint32(len(algStr))
otherInfo = append(otherInfo, byte(algLen>>24), byte(algLen>>16), byte(algLen>>8), byte(algLen))
otherInfo = append(otherInfo, algStr...)
otherInfo = append(otherInfo, 0, 0, 0, 0) // PartyUInfo
otherInfo = append(otherInfo, 0, 0, 0, 0) // PartyVInfo
otherInfo = append(otherInfo, 0, 0, 1, 0) // SuppPubInfo (256 bits BE)
// Since we need a 32-byte KEK, and SHA-256 produces 32 bytes, we run exactly 1 iteration (counter = 1)
counter := uint32(1)
hashInput := make([]byte, 0, 4+len(sharedSecret)+len(otherInfo))
hashInput = append(hashInput, byte(counter>>24), byte(counter>>16), byte(counter>>8), byte(counter))
hashInput = append(hashInput, sharedSecret...)
hashInput = append(hashInput, otherInfo...)
h := sha256.New()
h.Write(hashInput)
kek := h.Sum(nil)
// Unwrap the content encryption key (CEK)
cek, err := unwrapKey(encryptedResource.EncryptedKey, kek)
if err != nil {
return nil, err
}
// Decrypt the actual content using the CEK
plaintext, err := DecryptAESGCM(
encryptedResource.Ciphertext,
cek,
encryptedResource.IV,
encryptedResource.Tag,
encryptedResource.AAD,
)
if err != nil {
return nil, err
}
// Zero out sensitive key material
zeroBytes(kek)
zeroBytes(cek)
zeroBytes(sharedSecret)
return plaintext, nil
}
// unwrapKey unwraps an encrypted key using AES Key Wrap (RFC 3394).
func unwrapKey(wrappedKey, kek []byte) ([]byte, error) {
if len(wrappedKey)%8 != 0 || len(wrappedKey) < 24 {
return nil, errors.Wrap(ErrInvalidKey, errors.New("wrapped key length must be a multiple of 8 and at least 24 bytes"))
}
block, err := aes.NewCipher(kek)
if err != nil {
return nil, errors.Wrap(ErrDecryptionFailed, err)
}
n := len(wrappedKey)/8 - 1
r := make([][]byte, n+1)
r[0] = wrappedKey[:8]
for i := 1; i <= n; i++ {
r[i] = wrappedKey[i*8 : (i+1)*8]
}
a := r[0]
for j := 5; j >= 0; j-- {
for i := n; i >= 1; i-- {
t := uint64(n*j + i)
b := make([]byte, 16)
for k := 0; k < 8; k++ {
b[k] = a[k] ^ byte(t>>(56-8*k))
}
copy(b[8:], r[i])
block.Decrypt(b, b)
a = b[:8]
r[i] = b[8:]
}
}
// Check integrity value
expectedIV := []byte{0xA6, 0xA6, 0xA6, 0xA6, 0xA6, 0xA6, 0xA6, 0xA6}
for i := 0; i < 8; i++ {
if a[i] != expectedIV[i] {
return nil, errors.Wrap(ErrDecryptionFailed, errors.New("key unwrap integrity check failed"))
}
}
// Concatenate unwrapped key
unwrapped := make([]byte, 0, n*8)
for i := 1; i <= n; i++ {
unwrapped = append(unwrapped, r[i]...)
}
return unwrapped, nil
}
func decodeBase64(s string) ([]byte, error) {
if d, err := base64.StdEncoding.DecodeString(s); err == nil {
return d, nil
}
if d, err := base64.URLEncoding.DecodeString(s); err == nil {
return d, nil
}
if d, err := base64.RawURLEncoding.DecodeString(s); err == nil {
return d, nil
}
if d, err := base64.RawStdEncoding.DecodeString(s); err == nil {
return d, nil
}
return nil, errors.New("invalid base64 encoding")
}
// ParseEncryptedResource parses a JSON-encoded encrypted resource.
func ParseEncryptedResource(data []byte) (*EncryptedResource, error) {
var jwe struct {
Protected string `json:"protected"`
EncryptedKey string `json:"encrypted_key"`
IV string `json:"iv"`
Ciphertext string `json:"ciphertext"`
Tag string `json:"tag"`
}
if err := json.Unmarshal(data, &jwe); err != nil {
return nil, errors.Wrap(ErrInvalidFormat, err)
}
// JWE structure check: if it lacks protected header, try legacy standard struct unmarshal
if jwe.Protected == "" {
var legacy struct {
Ciphertext string `json:"ciphertext"`
EncryptedKey string `json:"encrypted_key"`
IV string `json:"iv"`
Tag string `json:"tag"`
AAD string `json:"aad,omitempty"`
EPK *EphemeralPublicKey `json:"epk,omitempty"`
}
if err := json.Unmarshal(data, &legacy); err != nil {
return nil, errors.Wrap(ErrInvalidFormat, err)
}
ciphertext, err := decodeBase64(legacy.Ciphertext)
if err != nil {
return nil, errors.Wrap(ErrInvalidFormat, err)
}
encryptedKey, err := decodeBase64(legacy.EncryptedKey)
if err != nil {
return nil, errors.Wrap(ErrInvalidFormat, err)
}
iv, err := decodeBase64(legacy.IV)
if err != nil {
return nil, errors.Wrap(ErrInvalidFormat, err)
}
tag, err := decodeBase64(legacy.Tag)
if err != nil {
return nil, errors.Wrap(ErrInvalidFormat, err)
}
var aad []byte
if legacy.AAD != "" {
aad, err = decodeBase64(legacy.AAD)
if err != nil {
return nil, errors.Wrap(ErrInvalidFormat, err)
}
}
return &EncryptedResource{
Ciphertext: ciphertext,
EncryptedKey: encryptedKey,
IV: iv,
Tag: tag,
AAD: aad,
EPK: legacy.EPK,
}, nil
}
// 1. Decode Protected Header JSON
protectedJSON, err := decodeBase64(jwe.Protected)
if err != nil {
return nil, errors.Wrap(ErrInvalidFormat, fmt.Errorf("failed to decode JWE protected header: %w", err))
}
// 2. Parse Ephemeral Public Key (EPK) from Protected Header
var header struct {
Alg string `json:"alg"`
EPK *EphemeralPublicKey `json:"epk"`
}
if err := json.Unmarshal(protectedJSON, &header); err != nil {
return nil, errors.Wrap(ErrInvalidFormat, fmt.Errorf("failed to parse JWE header JSON: %w", err))
}
// 3. Decode main crypto fields
ciphertext, err := decodeBase64(jwe.Ciphertext)
if err != nil {
return nil, errors.Wrap(ErrInvalidFormat, err)
}
encryptedKey, err := decodeBase64(jwe.EncryptedKey)
if err != nil {
return nil, errors.Wrap(ErrInvalidFormat, err)
}
iv, err := decodeBase64(jwe.IV)
if err != nil {
return nil, errors.Wrap(ErrInvalidFormat, err)
}
tag, err := decodeBase64(jwe.Tag)
if err != nil {
return nil, errors.Wrap(ErrInvalidFormat, err)
}
// In JWE, AAD is the ASCII bytes of the protected header string
aad := []byte(jwe.Protected)
return &EncryptedResource{
Ciphertext: ciphertext,
EncryptedKey: encryptedKey,
IV: iv,
Tag: tag,
AAD: aad,
EPK: header.EPK,
}, nil
}
// zeroBytes securely zeros out a byte slice.
func zeroBytes(b []byte) {
for i := range b {
b[i] = 0
}
}