Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Assertion verification #452

Open
wants to merge 19 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 16 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
11 changes: 10 additions & 1 deletion cmd/tdf-decrypt.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import (

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 @@ -35,7 +37,7 @@ func dev_tdfDecryptCmd(cmd *cobra.Command, args []string) {
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 +76,13 @@ func init() {
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
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:
elizabethhealy marked this conversation as resolved.
Show resolved Hide resolved
```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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should only support the file method. A user may not be aware of the risks of specifying the key as a command argument.

We could consider adding argument support after #417 is implemented.

Copy link
Member Author

@elizabethhealy elizabethhealy Dec 11, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think keeping the json string options was just to maintain backward compatability, we can add some more documentation here to emphasize that its deprecated and the file path is the more safe option

---

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-----..."}}]
elizabethhealy marked this conversation as resolved.
Show resolved Hide resolved
```
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" {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
@test "roundtrip TDF3, assertions with HS265 keys and verificaion, file" {
@test "roundtrip TDF3, assertions with HS265 keys and verification, 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" {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
@test "roundtrip TDF3, assertions with RS256 keys and verificaion, file" {
@test "roundtrip TDF3, assertions with RS256 keys and verification, 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are assertions present in the manifest? If so, can we add a test with ./otdfctl inspect that asserts they were found present in the TDF created?

./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
134 changes: 126 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"
"os"
"strings"

"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 := readBytesFromFile(assertions)
if err != nil {
return nil, errors.Join(ErrTDFUnableToReadAssertions, err)
elizabethhealy marked this conversation as resolved.
Show resolved Hide resolved
}
err = json.Unmarshal(assertionBytes, &assertionConfigs)
if err != nil {
return nil, errors.Join(ErrTDFUnableToReadAssertions, err)
}
}
for i, config := range assertionConfigs {
if (config.SigningKey != sdk.AssertionKey{}) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this comparison struct equality evaluate as expected? It seems like you'd need to compare struct fields for this equality check?

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, assertionVerification string, disableAssertionCheck bool) (*bytes.Buffer, error) {
elizabethhealy marked this conversation as resolved.
Show resolved Hide resolved
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 assertionVerification != "" {
// read the file
assertionVerificationBytes, err := readBytesFromFile(assertionVerification)
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,77 @@ func (h Handler) InspectTDF(toInspect []byte) (TDFInspect, []error) {
return TDFInspect{}, []error{fmt.Errorf("tdf format unrecognized")}
}
}

func readBytesFromFile(filePath string) ([]byte, error) {
elizabethhealy marked this conversation as resolved.
Show resolved Hide resolved
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
}

func correctKeyType(assertionKey sdk.AssertionKey, public bool) (interface{}, error) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think this functionality could be exposed within provided platform lib/SDK functionality instead of recreated within each Go PEP?

Copy link
Member Author

@elizabethhealy elizabethhealy Dec 13, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems like most go packages that deal with crypto just take in args that are already typed rsa.PrivateKey or rsa.PublicKey, they assume you already have the key and if not they expect you to load it yourself. i think the cli has a bit extra code since the whole assertion object is being passed in as a string which isnt how it would normally be done in a go pep. i assume the pep would just construct the assertion similar to https://github.com/opentdf/platform/blob/7d55a909ac03e5a1234709d9051f906d9efd469e/sdk/tdf_test.go#L400-L425 and would just load the key from pem; also the cli has extra conditions to handle private vs public where as i assume a pep would know if it was a private key or public key they were loading. so TLDR i think the cli is more of an outlier in how its passing in assertions and the extra steps needed to parse them and want to avoid adding specific logic to the sdk just for the cli.
but if its determined that other peps have this need as well we can re-assess

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)
}
Loading