Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 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
14 changes: 0 additions & 14 deletions cmd/dev.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,20 +132,6 @@ func readPipedStdin() []byte {
return nil
}

func readBytesFromFile(filePath string) []byte {
fileToEncrypt, err := os.Open(filePath)
if err != nil {
cli.ExitWithError(fmt.Sprintf("Failed to open file at path: %s", filePath), err)
}
defer fileToEncrypt.Close()

bytes, err := io.ReadAll(fileToEncrypt)
if err != nil {
cli.ExitWithError(fmt.Sprintf("Failed to read bytes from file at path: %s", filePath), err)
}
return bytes
}

func init() {
designCmd := man.Docs.GetCommand("dev/design-system",
man.WithRun(dev_designSystem),
Expand Down
16 changes: 14 additions & 2 deletions cmd/tdf-decrypt.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@

"github.com/opentdf/otdfctl/pkg/cli"
"github.com/opentdf/otdfctl/pkg/man"
"github.com/opentdf/otdfctl/pkg/utils"
"github.com/spf13/cobra"
)

var TDF = "tdf"

var assertionVerification string

func dev_tdfDecryptCmd(cmd *cobra.Command, args []string) {
c := cli.New(cmd, args, cli.WithPrintJson())
h := NewHandler(c)
Expand All @@ -26,16 +29,18 @@
// Prefer file argument over piped input over default filename
bytesToDecrypt := piped
var tdfFile string
var err error
if len(args) > 0 {
tdfFile = args[0]
bytesToDecrypt = readBytesFromFile(tdfFile)
bytesToDecrypt, err = utils.ReadBytesFromFile(tdfFile)

Check failure on line 35 in cmd/tdf-decrypt.go

View workflow job for this annotation

GitHub Actions / lint

SA4006: this value of `bytesToDecrypt` is never used (staticcheck)
cli.ExitWithError("Failed to read file:", err)
}

if len(bytesToDecrypt) == 0 {
cli.ExitWithError("Must provide ONE of the following to decrypt: [file argument, stdin input]", errors.New("no input provided"))
}

decrypted, err := h.DecryptBytes(bytesToDecrypt, disableAssertionVerification)
decrypted, err := h.DecryptBytes(bytesToDecrypt, assertionVerification, disableAssertionVerification)
if err != nil {
cli.ExitWithError("Failed to decrypt file", err)
}
Expand Down Expand Up @@ -74,6 +79,13 @@
decryptCmd.GetDocFlag("tdf-type").Default,
decryptCmd.GetDocFlag("tdf-type").Description,
)
decryptCmd.Flags().StringVarP(
&assertionVerification,
decryptCmd.GetDocFlag("with-assertion-verification-keys").Name,
decryptCmd.GetDocFlag("with-assertion-verification-keys").Shorthand,
"",
decryptCmd.GetDocFlag("with-assertion-verification-keys").Description,
)
decryptCmd.Flags().Bool(
decryptCmd.GetDocFlag("no-verify-assertions").Name,
decryptCmd.GetDocFlag("no-verify-assertions").DefaultAsBool(),
Expand Down
5 changes: 4 additions & 1 deletion cmd/tdf-encrypt.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"github.com/gabriel-vasile/mimetype"
"github.com/opentdf/otdfctl/pkg/cli"
"github.com/opentdf/otdfctl/pkg/man"
"github.com/opentdf/otdfctl/pkg/utils"
"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -63,8 +64,10 @@

// prefer filepath argument over stdin input
bytesSlice := piped
var err error
if filePath != "" {
bytesSlice = readBytesFromFile(filePath)
bytesSlice, err = utils.ReadBytesFromFile(filePath)

Check failure on line 69 in cmd/tdf-encrypt.go

View workflow job for this annotation

GitHub Actions / lint

SA4006: this value of `bytesSlice` is never used (staticcheck)
cli.ExitWithError("Failed to read file:", err)
}

// auto-detect mime type if not provided
Expand Down
13 changes: 13 additions & 0 deletions docs/man/decrypt/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ command:
- name: no-verify-assertions
description: disable verification of assertions
default: false
- name: with-assertion-verification-keys
description: >
EXPERIMENTAL: path to JSON file of keys to verify signed assertions. See examples for more information.
---

Decrypt a Trusted Data Format (TDF) file and output the contents to stdout or a file in the current working directory.
Expand Down Expand Up @@ -40,3 +43,13 @@ Advanced piping is supported
$ echo "hello world" | otdfctl encrypt | otdfctl decrypt | cat
hello world
```

Assertion verification:
```shell
# decrypt file and write to standard output
otdfctl decrypt hello.txt.tdf --with-assertion-verification-keys my_assertion_verification_keys.json
```
Where my_assertion_verification_keys.json look like:
```json
{"keys":{"assertion1":{ "alg":"HS256","key":"xxxx"},"assertion2":{ "alg":"RS256","key":"-----BEGIN PUBLIC KEY-----..."}}}
```
5 changes: 3 additions & 2 deletions docs/man/encrypt/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ command:
default: /kas
- name: with-assertions
description: >
EXPERIMENTAL: JSON string of assertions to bind metadata to the TDF. See examples for more information.
EXPERIMENTAL: JSON string or path to JSON file of assertions to bind metadata to the TDF. See examples for more information.
---

Build a Trusted Data Format (TDF) with encrypted content from a specified file or input from stdin utilizing OpenTDF platform.
Expand Down Expand Up @@ -93,5 +93,6 @@ Assertions are a way to bind metadata to the TDF data object in a cryptographica
The following example demonstrates how to bind a STANAG 5636 metadata assertion to the TDF data object.

```shell
otdfctl encrypt hello.txt --out hello.txt.tdf --with-assertions '[{"id":"assertion1","type":"handling","scope":"tdo","appliesToState":"encrypted","statement":{"format":"json+stanag5636","schema":"urn:nato:stanag:5636:A:1:elements:json","value":"{\"ocl\":\"2024-10-21T20:47:36Z\"}"}}]'
otdfctl encrypt hello.txt --out hello.txt.tdf --with-assertions '[{"id":"assertion1","type":"handling","scope":"tdo","appliesToState":"encrypted","statement":{"format":"json+stanag5636","schema":"urn:nato:stanag:5636:A:1:elements:json","value":"{\"ocl\":\"2024-10-21T20:47:36Z\"},"signingKey":{"alg":"RS256","key":"-----BEGIN PRIVATE KEY-----..."}}]
```
Signing with HS256 is also available.
38 changes: 37 additions & 1 deletion e2e/encrypt-decrypt.bats
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,30 @@ setup_file() {
VAL_ID=$(./otdfctl --host $HOST $WITH_CREDS $DEBUG_LEVEL policy attributes values create --attribute-id "$ATTR_ID" -v value1 --json | jq -r '.id')
# entitles opentdf client id for client credentials CLI user
SCS='[{"condition_groups":[{"conditions":[{"operator":1,"subject_external_values":["opentdf"],"subject_external_selector_value":".clientId"}],"boolean_operator":2}]}]'
ASSERTIONS='[{"id":"assertion1","type":"handling","scope":"tdo","appliesToState":"encrypted","statement":{"format":"json+stanag5636","schema":"urn:nato:stanag:5636:A:1:elements:json","value":"{\"ocl\":\"2024-10-21T20:47:36Z\"}"}}]'

# assertions setup
HS256_KEY=$(openssl rand -base64 32)
RS_PRIVATE_KEY=rs_private_key.pem
RS_PUBLIC_KEY=rs_public_key.pem
openssl genpkey -algorithm RSA -out $RS_PRIVATE_KEY -pkeyopt rsa_keygen_bits:2048
openssl rsa -pubout -in $RS_PRIVATE_KEY -out $RS_PUBLIC_KEY

export ASSERTIONS='[{"id":"assertion1","type":"handling","scope":"tdo","appliesToState":"encrypted","statement":{"format":"json+stanag5636","schema":"urn:nato:stanag:5636:A:1:elements:json","value":"{\"ocl\":\"2024-10-21T20:47:36Z\"}"}}]'

export SIGNED_ASSERTIONS_HS256=signed_assertions_hs256.json
export SIGNED_ASSERTION_VERIFICATON_HS256=assertion_verification_hs256.json
export SIGNED_ASSERTIONS_RS256=signed_assertion_rs256.json
export SIGNED_ASSERTION_VERIFICATON_RS256=assertion_verification_rs256.json
echo '[{"id":"assertion1","type":"handling","scope":"tdo","appliesToState":"encrypted","statement":{"format":"json+stanag5636","schema":"urn:nato:stanag:5636:A:1:elements:json","value":"{\"ocl\":\"2024-10-21T20:47:36Z\"}"},"signingKey":{"alg":"HS256","key":"replace"}}]' > $SIGNED_ASSERTIONS_HS256
jq --arg pem "$(echo $HS256_KEY)" '.[0].signingKey.key = $pem' $SIGNED_ASSERTIONS_HS256 > tmp.json && mv tmp.json $SIGNED_ASSERTIONS_HS256
echo '{"keys":{"assertion1":{"alg":"HS256","key":"replace"}}}' > $SIGNED_ASSERTION_VERIFICATON_HS256
jq --arg pem "$(echo $HS256_KEY)" '.keys.assertion1.key = $pem' $SIGNED_ASSERTION_VERIFICATON_HS256 > tmp.json && mv tmp.json $SIGNED_ASSERTION_VERIFICATON_HS256
echo '[{"id":"assertion1","type":"handling","scope":"tdo","appliesToState":"encrypted","statement":{"format":"json+stanag5636","schema":"urn:nato:stanag:5636:A:1:elements:json","value":"{\"ocl\":\"2024-10-21T20:47:36Z\"}"},"signingKey":{"alg":"RS256","key":"replace"}}]' > $SIGNED_ASSERTIONS_RS256
jq --arg pem "$(<$RS_PRIVATE_KEY)" '.[0].signingKey.key = $pem' $SIGNED_ASSERTIONS_RS256 > tmp.json && mv tmp.json $SIGNED_ASSERTIONS_RS256
echo '{"keys":{"assertion1":{"alg":"RS256","key":"replace"}}}' > $SIGNED_ASSERTION_VERIFICATON_RS256
jq --arg pem "$(<$RS_PUBLIC_KEY)" '.keys.assertion1.key = $pem' $SIGNED_ASSERTION_VERIFICATON_RS256 > tmp.json && mv tmp.json $SIGNED_ASSERTION_VERIFICATON_RS256


SM=$(./otdfctl --host $HOST $WITH_CREDS $DEBUG_LEVEL policy subject-mappings create --action-standard DECRYPT -a "$VAL_ID" --subject-condition-set-new "$SCS")
export FQN="https://testing-enc-dec.io/attr/attr1/value/value1"
export MIXED_CASE_FQN="https://Testing-Enc-Dec.io/attr/Attr1/value/VALUE1"
Expand All @@ -34,6 +57,7 @@ teardown() {

teardown_file(){
./otdfctl --host "$HOST" $WITH_CREDS policy attributes namespaces unsafe delete --id "$NS_ID" --force
rm -f $SIGNED_ASSERTIONS_HS256 $SIGNED_ASSERTION_VERIFICATON_HS256 $SIGNED_ASSERTIONS_RS256 $SIGNED_ASSERTION_VERIFICATON_RS256
}

@test "roundtrip TDF3, no attributes, file" {
Expand All @@ -57,6 +81,18 @@ teardown_file(){
./otdfctl decrypt --host $HOST --tls-no-verify $DEBUG_LEVEL $WITH_CREDS $OUTFILE_TXT | grep "$SECRET_TEXT"
}

@test "roundtrip TDF3, assertions with HS265 keys and verificaion, file" {
./otdfctl encrypt -o $OUTFILE_GO_MOD --host $HOST --tls-no-verify $DEBUG_LEVEL $WITH_CREDS -a $FQN --with-assertions $SIGNED_ASSERTIONS_HS256 --tdf-type tdf3 $INFILE_GO_MOD
./otdfctl decrypt -o $RESULTFILE_GO_MOD --host $HOST --tls-no-verify $DEBUG_LEVEL $WITH_CREDS --with-assertion-verification-keys $SIGNED_ASSERTION_VERIFICATON_HS256 --tdf-type tdf3 $OUTFILE_GO_MOD
diff $INFILE_GO_MOD $RESULTFILE_GO_MOD
}

@test "roundtrip TDF3, assertions with RS256 keys and verificaion, file" {
./otdfctl encrypt -o $OUTFILE_GO_MOD --host $HOST --tls-no-verify $DEBUG_LEVEL $WITH_CREDS -a $FQN --with-assertions $SIGNED_ASSERTIONS_RS256 --tdf-type tdf3 $INFILE_GO_MOD
./otdfctl decrypt -o $RESULTFILE_GO_MOD --host $HOST --tls-no-verify $DEBUG_LEVEL $WITH_CREDS --with-assertion-verification-keys $SIGNED_ASSERTION_VERIFICATON_RS256 --tdf-type tdf3 $OUTFILE_GO_MOD
diff $INFILE_GO_MOD $RESULTFILE_GO_MOD
}

@test "roundtrip NANO, no attributes, file" {
./otdfctl encrypt -o $OUTFILE_GO_MOD --host $HOST --tls-no-verify $DEBUG_LEVEL $WITH_CREDS --tdf-type nano $INFILE_GO_MOD
./otdfctl decrypt -o $RESULTFILE_GO_MOD --host $HOST --tls-no-verify $DEBUG_LEVEL $WITH_CREDS --tdf-type nano $OUTFILE_GO_MOD
Expand Down
120 changes: 112 additions & 8 deletions pkg/handlers/tdf.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,26 @@ package handlers

import (
"bytes"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"strings"

"github.com/opentdf/otdfctl/pkg/utils"
"github.com/opentdf/platform/sdk"
)

var (
ErrTDFInspectFailNotValidTDF = errors.New("file or input is not a valid TDF")
ErrTDFInspectFailNotInspectable = errors.New("file or input is not inspectable")
ErrTDFUnableToReadAttributes = errors.New("unable to read attributes from TDF")
ErrTDFUnableToReadUnencryptedMetadata = errors.New("unable to read unencrypted metadata from TDF")
ErrTDFUnableToReadAssertions = errors.New("unable to read assertions")
ErrTDFInspectFailNotValidTDF = errors.New("file or input is not a valid TDF")
ErrTDFInspectFailNotInspectable = errors.New("file or input is not inspectable")
ErrTDFUnableToReadAttributes = errors.New("unable to read attributes from TDF")
ErrTDFUnableToReadUnencryptedMetadata = errors.New("unable to read unencrypted metadata from TDF")
ErrTDFUnableToReadAssertions = errors.New("unable to read assertions")
ErrTDFUnableToReadAssertionVerificationKeys = errors.New("unable to read assertion verification keys")
)

const (
Expand Down Expand Up @@ -52,10 +57,28 @@ func (h Handler) EncryptBytes(tdfType string, unencrypted []byte, attrValues []s
}

var assertionConfigs []sdk.AssertionConfig
//nolint:nestif // nested its mainly for error catching and handling case of string vs file
if assertions != "" {
err := json.Unmarshal([]byte(assertions), &assertionConfigs)
if err != nil {
return nil, errors.Join(ErrTDFUnableToReadAssertions, err)
// if unable to marshal to json, interpret as file string and try to read from file
assertionBytes, err := utils.ReadBytesFromFile(assertions)
if err != nil {
return nil, errors.Join(ErrTDFUnableToReadAssertions, err)
}
err = json.Unmarshal(assertionBytes, &assertionConfigs)
if err != nil {
return nil, errors.Join(ErrTDFUnableToReadAssertions, err)
}
}
for i, config := range assertionConfigs {
if (config.SigningKey != sdk.AssertionKey{}) {
correctedKey, err := correctKeyType(config.SigningKey, false)
if err != nil {
return nil, fmt.Errorf("error with assertion signing key: %w", err)
}
assertionConfigs[i].SigningKey.Key = correctedKey
}
}
opts = append(opts, sdk.WithAssertions(assertionConfigs...))
}
Expand Down Expand Up @@ -91,7 +114,7 @@ func (h Handler) EncryptBytes(tdfType string, unencrypted []byte, attrValues []s
}
}

func (h Handler) DecryptBytes(toDecrypt []byte, disableAssertionCheck bool) (*bytes.Buffer, error) {
func (h Handler) DecryptBytes(toDecrypt []byte, assertionVerificationKeysFile string, disableAssertionCheck bool) (*bytes.Buffer, error) {
out := &bytes.Buffer{}
pt := io.Writer(out)
ec := bytes.NewReader(toDecrypt)
Expand All @@ -101,7 +124,28 @@ func (h Handler) DecryptBytes(toDecrypt []byte, disableAssertionCheck bool) (*by
return nil, err
}
case sdk.Standard:
r, err := h.sdk.LoadTDF(ec, sdk.WithDisableAssertionVerification(disableAssertionCheck))
opts := []sdk.TDFReaderOption{sdk.WithDisableAssertionVerification(disableAssertionCheck)}
var assertionVerificationKeys sdk.AssertionVerificationKeys
if assertionVerificationKeysFile != "" {
// read the file
assertionVerificationBytes, err := utils.ReadBytesFromFile(assertionVerificationKeysFile)
if err != nil {
return nil, errors.Join(ErrTDFUnableToReadAssertionVerificationKeys, err)
}
err = json.Unmarshal(assertionVerificationBytes, &assertionVerificationKeys)
if err != nil {
return nil, errors.Join(ErrTDFUnableToReadAssertionVerificationKeys, err)
}
for assertionName, key := range assertionVerificationKeys.Keys {
correctedKey, err := correctKeyType(key, true)
if err != nil {
return nil, fmt.Errorf("error with assertion signing key: %w", err)
}
assertionVerificationKeys.Keys[assertionName] = sdk.AssertionKey{Alg: key.Alg, Key: correctedKey}
}
opts = append(opts, sdk.WithAssertionVerificationKeys(assertionVerificationKeys))
}
r, err := h.sdk.LoadTDF(ec, opts...)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -170,3 +214,63 @@ func (h Handler) InspectTDF(toInspect []byte) (TDFInspect, []error) {
return TDFInspect{}, []error{fmt.Errorf("tdf format unrecognized")}
}
}

func correctKeyType(assertionKey sdk.AssertionKey, public bool) (interface{}, error) {
strKey, ok := assertionKey.Key.(string)
if !ok {
return nil, errors.New("unable to convert assertion key to string")
}
//nolint:nestif // nested its within switch mainly for error catching
if assertionKey.Alg == sdk.AssertionKeyAlgHS256 {
// convert the hs256 key to []byte
return []byte(strKey), nil
} else if assertionKey.Alg == sdk.AssertionKeyAlgRS256 {
// Decode the PEM block
block, _ := pem.Decode([]byte(strKey))
if block == nil {
return nil, errors.New("failed to decode PEM block")
}

// Check the block type and parse accordingly
var privateKey *rsa.PrivateKey
var publicKey *rsa.PublicKey
var err error
switch block.Type {
case "RSA PRIVATE KEY":
privateKey, err = x509.ParsePKCS1PrivateKey(block.Bytes)
publicKey = &privateKey.PublicKey
case "PRIVATE KEY":
parsedKey, parseErr := x509.ParsePKCS8PrivateKey(block.Bytes)
if parseErr != nil {
return nil, fmt.Errorf("failed to parse PKCS#8 private key: %w", parseErr)
}
privateKey, ok = parsedKey.(*rsa.PrivateKey)
if !ok {
return nil, errors.New("parsed key is not an RSA private key")
}
publicKey = &privateKey.PublicKey
case "RSA PUBLIC KEY":
publicKey, err = x509.ParsePKCS1PublicKey(block.Bytes)
case "PUBLIC KEY":
parsedKey, parseErr := x509.ParsePKIXPublicKey(block.Bytes)
if parseErr != nil {
return nil, fmt.Errorf("failed to parse PKIX public key: %w", parseErr)
}
publicKey, ok = parsedKey.(*rsa.PublicKey)
if !ok {
return nil, errors.New("parsed key is not an RSA public key")
}
default:
return nil, fmt.Errorf("unsupported key type: %s", block.Type)
}

if err != nil {
return nil, fmt.Errorf("failed to parse private key: %w", err)
}
if public {
return publicKey, nil
}
return privateKey, nil
}
return nil, fmt.Errorf("unsupported signing key alg: %v", assertionKey.Alg)
}
21 changes: 21 additions & 0 deletions pkg/utils/read.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package utils

import (
"fmt"
"io"
"os"
)

func ReadBytesFromFile(filePath string) ([]byte, error) {
fileToEncrypt, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("failed to open file at path %s: %w", filePath, err)
}
defer fileToEncrypt.Close()

bytes, err := io.ReadAll(fileToEncrypt)
if err != nil {
return nil, fmt.Errorf("failed to read bytes from file at path %s: %w", filePath, err)
}
return bytes, nil
}
Loading