Skip to content

Commit 61316ef

Browse files
feat(otdfctl): add --dpop and --dpop-key flags to encrypt/decrypt (DSPX-3397)
Exposes DPoP algorithm/key selection via CLI flags on `otdfctl encrypt` and `otdfctl decrypt`, supporting ES256 (default), ES384, ES512, RS256, RS384, and RS512. Bare `--dpop` defaults to ES256 per RFC 9449 §4.2. `--dpop-key <path>` loads a PEM private key (algorithm inferred from key type). Both flags can be combined to override the inferred algorithm. SDK changes: - Add sdk/dpop_key.go: generateDPoPKeyForAlg, loadDPoPKeyFromPEM, resolveDPoPKey helpers - Add WithDPoPAlgorithm, WithDPoPKeyPEM, WithDPoPJWK SDK options - Thread custom JWK through buildIDPTokenSource and DPoPTransport setup; falls back to auto-generated RSA when no custom key is configured - Add JWK-accepting token source constructors for all four source types otdfctl changes: - Register --dpop (NoOptDefVal="ES256") and --dpop-key flags on encrypt/decrypt; update man docs accordingly - handlers.WithExtraSDKOpts appends (not replaces) SDK options - common.NewHandler accepts variadic extraSDKOpts (backward compatible) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
1 parent 2afd6eb commit 61316ef

17 files changed

Lines changed: 545 additions & 18 deletions

otdfctl/cmd/common/common.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ func InitProfile(c *cli.Cli) *profiles.OtdfctlProfileStore {
8686
// TODO make this a preRun hook
8787
//
8888
//nolint:nestif // separate refactor [https://github.com/opentdf/otdfctl/issues/383]
89-
func NewHandler(c *cli.Cli) handlers.Handler {
89+
func NewHandler(c *cli.Cli, extraSDKOpts ...sdk.Option) handlers.Handler {
9090
// if global flags are set then validate and create a temporary profile in memory
9191
var cp *profiles.OtdfctlProfileStore
9292

@@ -209,7 +209,11 @@ func NewHandler(c *cli.Cli) handlers.Handler {
209209
cli.ExitWithError("Failed to get access token.", err)
210210
}
211211

212-
h, err := handlers.New(handlers.WithProfile(cp))
212+
handlerFuncs := []handlers.HandlerOptsFunc{handlers.WithProfile(cp)}
213+
if len(extraSDKOpts) > 0 {
214+
handlerFuncs = append(handlerFuncs, handlers.WithExtraSDKOpts(extraSDKOpts...))
215+
}
216+
h, err := handlers.New(handlerFuncs...)
213217
if err != nil {
214218
cli.ExitWithError("Unexpected error", err)
215219
}

otdfctl/cmd/tdf/decrypt.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ var (
2323

2424
func decryptRun(cmd *cobra.Command, args []string) {
2525
c := cli.New(cmd, args, cli.WithPrintJSON())
26-
h := common.NewHandler(c)
26+
h := common.NewHandler(c, dpopSDKOpts(c)...)
2727
defer h.Close()
2828

2929
output := c.Flags.GetOptionalString("out")
@@ -133,6 +133,18 @@ func InitDecryptCommand() {
133133
nil,
134134
decryptDoc.GetDocFlag("kas-allowlist").Description,
135135
)
136+
decryptDoc.Flags().String(
137+
decryptDoc.GetDocFlag("dpop").Name,
138+
decryptDoc.GetDocFlag("dpop").Default,
139+
decryptDoc.GetDocFlag("dpop").Description,
140+
)
141+
// NoOptDefVal enables bare --dpop (without =value) to default to ES256.
142+
decryptDoc.Flags().Lookup(decryptDoc.GetDocFlag("dpop").Name).NoOptDefVal = "ES256"
143+
decryptDoc.Flags().String(
144+
decryptDoc.GetDocFlag("dpop-key").Name,
145+
decryptDoc.GetDocFlag("dpop-key").Default,
146+
decryptDoc.GetDocFlag("dpop-key").Description,
147+
)
136148

137149
decryptDoc.GroupID = TDF
138150
}

otdfctl/cmd/tdf/dpop.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package tdf
2+
3+
import (
4+
"os"
5+
6+
"github.com/opentdf/platform/otdfctl/pkg/cli"
7+
"github.com/opentdf/platform/sdk"
8+
)
9+
10+
// dpopSDKOpts reads --dpop and --dpop-key flags from the CLI and returns the
11+
// corresponding SDK options. Returns an empty slice when DPoP is not configured.
12+
func dpopSDKOpts(c *cli.Cli) []sdk.Option {
13+
dpopAlg := c.Flags.GetOptionalString("dpop")
14+
dpopKeyPath := c.Flags.GetOptionalString("dpop-key")
15+
16+
var opts []sdk.Option
17+
if dpopKeyPath != "" {
18+
pemBytes, err := os.ReadFile(dpopKeyPath)
19+
if err != nil {
20+
cli.ExitWithError("Failed to read DPoP key file", err)
21+
}
22+
opts = append(opts, sdk.WithDPoPKeyPEM(pemBytes))
23+
if dpopAlg != "" {
24+
opts = append(opts, sdk.WithDPoPAlgorithm(dpopAlg))
25+
}
26+
} else if dpopAlg != "" {
27+
opts = append(opts, sdk.WithDPoPAlgorithm(dpopAlg))
28+
}
29+
return opts
30+
}

otdfctl/cmd/tdf/encrypt.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ var (
2626

2727
func encryptRun(cmd *cobra.Command, args []string) {
2828
c := cli.New(cmd, args, cli.WithPrintJSON())
29-
h := common.NewHandler(c)
29+
h := common.NewHandler(c, dpopSDKOpts(c)...)
3030
defer h.Close()
3131

3232
var filePath string
@@ -191,5 +191,17 @@ func InitEncryptCommand() {
191191
encryptDoc.GetDocFlag("target-mode").Default,
192192
encryptDoc.GetDocFlag("target-mode").Description,
193193
)
194+
encryptDoc.Flags().String(
195+
encryptDoc.GetDocFlag("dpop").Name,
196+
encryptDoc.GetDocFlag("dpop").Default,
197+
encryptDoc.GetDocFlag("dpop").Description,
198+
)
199+
// NoOptDefVal enables bare --dpop (without =value) to default to ES256.
200+
encryptDoc.Flags().Lookup(encryptDoc.GetDocFlag("dpop").Name).NoOptDefVal = "ES256"
201+
encryptDoc.Flags().String(
202+
encryptDoc.GetDocFlag("dpop-key").Name,
203+
encryptDoc.GetDocFlag("dpop-key").Default,
204+
encryptDoc.GetDocFlag("dpop-key").Description,
205+
)
194206
encryptDoc.GroupID = TDF
195207
}

otdfctl/docs/man/decrypt/_index.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,16 @@ command:
2727
EXPERIMENTAL: path to JSON file of keys to verify signed assertions. See examples for more information.
2828
- name: kas-allowlist
2929
description: A custom allowlist of comma-separated KAS Urls, e.g. `https://example.com/kas,http://localhost:8080`. If none specified, the platform will use the list of KASes in the KAS registry. To ignore the allowlist, use a quoted wildcard e.g. `--kas-allowlist '*'` **WARNING:** Bypassing the allowlist may expose you to potential security risks, as untrusted KAS URLs could be used.
30+
- name: dpop
31+
description: >
32+
Enable DPoP (RFC 9449) sender-constrained tokens. Use bare --dpop for ES256 (default), or
33+
--dpop=<alg> for a specific algorithm. Allowed algorithms: ES256, ES384, ES512, RS256, RS384, RS512.
34+
An ephemeral key is generated per session. Combines with --dpop-key to override inferred algorithm.
35+
- name: dpop-key
36+
description: >
37+
Path to a PEM-encoded private key for DPoP. Enables DPoP without requiring --dpop.
38+
Algorithm is inferred from the key type (EC → ES256/384/512, RSA → RS256).
39+
Use --dpop=<alg> to override the inferred algorithm.
3040
---
3141

3242
Decrypt a Trusted Data Format (TDF) file and output the contents to stdout or a file in the current working directory.

otdfctl/docs/man/encrypt/_index.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,16 @@ command:
3737
- name: with-assertions
3838
description: >
3939
EXPERIMENTAL: JSON string or path to a JSON file of assertions to bind metadata to the TDF. See examples for more information. WARNING: Providing keys in a JSON string is strongly discouraged. If including sensitive keys, instead provide a path to a JSON file containing that information.
40+
- name: dpop
41+
description: >
42+
Enable DPoP (RFC 9449) sender-constrained tokens. Use bare --dpop for ES256 (default), or
43+
--dpop=<alg> for a specific algorithm. Allowed algorithms: ES256, ES384, ES512, RS256, RS384, RS512.
44+
An ephemeral key is generated per session. Combines with --dpop-key to override inferred algorithm.
45+
- name: dpop-key
46+
description: >
47+
Path to a PEM-encoded private key for DPoP. Enables DPoP without requiring --dpop.
48+
Algorithm is inferred from the key type (EC → ES256/384/512, RSA → RS256).
49+
Use --dpop=<alg> to override the inferred algorithm.
4050
---
4151

4252
Build a Trusted Data Format (TDF) with encrypted content from a specified file or input from stdin utilizing OpenTDF platform.

otdfctl/pkg/handlers/sdk.go

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,17 +31,17 @@ type handlerOpts struct {
3131
sdkOpts []sdk.Option
3232
}
3333

34-
type handlerOptsFunc func(handlerOpts) handlerOpts
34+
type HandlerOptsFunc func(handlerOpts) handlerOpts
3535

36-
func WithEndpoint(endpoint string, tlsNoVerify bool) handlerOptsFunc {
36+
func WithEndpoint(endpoint string, tlsNoVerify bool) HandlerOptsFunc {
3737
return func(c handlerOpts) handlerOpts {
3838
c.endpoint = endpoint
3939
c.TLSNoVerify = tlsNoVerify
4040
return c
4141
}
4242
}
4343

44-
func WithProfile(profile *profiles.OtdfctlProfileStore) handlerOptsFunc {
44+
func WithProfile(profile *profiles.OtdfctlProfileStore) HandlerOptsFunc {
4545
return func(c handlerOpts) handlerOpts {
4646
c.profile = profile
4747
c.endpoint = profile.GetEndpoint()
@@ -58,15 +58,23 @@ func WithProfile(profile *profiles.OtdfctlProfileStore) handlerOptsFunc {
5858
}
5959
}
6060

61-
func WithSDKOpts(opts ...sdk.Option) handlerOptsFunc {
61+
func WithSDKOpts(opts ...sdk.Option) HandlerOptsFunc {
6262
return func(c handlerOpts) handlerOpts {
6363
c.sdkOpts = opts
6464
return c
6565
}
6666
}
6767

68+
// WithExtraSDKOpts appends additional SDK options without replacing those set by WithProfile.
69+
func WithExtraSDKOpts(opts ...sdk.Option) HandlerOptsFunc {
70+
return func(c handlerOpts) handlerOpts {
71+
c.sdkOpts = append(c.sdkOpts, opts...)
72+
return c
73+
}
74+
}
75+
6876
// Creates a new handler wrapping the SDK, which is authenticated through the cached client-credentials flow tokens
69-
func New(opts ...handlerOptsFunc) (Handler, error) {
77+
func New(opts ...HandlerOptsFunc) (Handler, error) {
7078
var o handlerOpts
7179
for _, f := range opts {
7280
o = f(o)

sdk/auth/dpop_transport.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import (
88
"net/url"
99
"strings"
1010
"sync"
11-
1211
"time"
1312

1413
"github.com/google/uuid"

sdk/dpop_key.go

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
package sdk
2+
3+
import (
4+
"crypto/ecdsa"
5+
"crypto/elliptic"
6+
"crypto/rand"
7+
"crypto/rsa"
8+
"errors"
9+
"fmt"
10+
11+
"github.com/lestrrat-go/jwx/v2/jwa"
12+
"github.com/lestrrat-go/jwx/v2/jwk"
13+
)
14+
15+
// Supported DPoP algorithm identifiers (RFC 9449 §4.2).
16+
const (
17+
dpopAlgES256 = "ES256"
18+
dpopAlgES384 = "ES384"
19+
dpopAlgES512 = "ES512"
20+
dpopAlgRS256 = "RS256"
21+
dpopAlgRS384 = "RS384"
22+
dpopAlgRS512 = "RS512"
23+
)
24+
25+
const dpopAllowedAlgs = dpopAlgES256 + ", " + dpopAlgES384 + ", " + dpopAlgES512 + ", " +
26+
dpopAlgRS256 + ", " + dpopAlgRS384 + ", " + dpopAlgRS512
27+
28+
// generateDPoPKeyForAlg generates an ephemeral DPoP private key for the given algorithm.
29+
// Supported algorithms: ES256, ES384, ES512, RS256, RS384, RS512.
30+
func generateDPoPKeyForAlg(alg string) (jwk.Key, error) {
31+
switch alg {
32+
case dpopAlgES256:
33+
return generateECDSAKey(elliptic.P256(), jwa.ES256)
34+
case dpopAlgES384:
35+
return generateECDSAKey(elliptic.P384(), jwa.ES384)
36+
case dpopAlgES512:
37+
return generateECDSAKey(elliptic.P521(), jwa.ES512)
38+
case dpopAlgRS256:
39+
return generateRSAKey(jwa.RS256)
40+
case dpopAlgRS384:
41+
return generateRSAKey(jwa.RS384)
42+
case dpopAlgRS512:
43+
return generateRSAKey(jwa.RS512)
44+
default:
45+
return nil, fmt.Errorf("unsupported DPoP algorithm %q; allowed: %s", alg, dpopAllowedAlgs)
46+
}
47+
}
48+
49+
func generateECDSAKey(curve elliptic.Curve, alg jwa.SignatureAlgorithm) (jwk.Key, error) {
50+
rawKey, err := ecdsa.GenerateKey(curve, rand.Reader)
51+
if err != nil {
52+
return nil, fmt.Errorf("failed to generate ECDSA key: %w", err)
53+
}
54+
key, err := jwk.FromRaw(rawKey)
55+
if err != nil {
56+
return nil, fmt.Errorf("failed to create JWK from ECDSA key: %w", err)
57+
}
58+
if err := key.Set(jwk.AlgorithmKey, alg); err != nil {
59+
return nil, fmt.Errorf("failed to set algorithm on ECDSA JWK: %w", err)
60+
}
61+
return key, nil
62+
}
63+
64+
func generateRSAKey(alg jwa.SignatureAlgorithm) (jwk.Key, error) {
65+
const rsaBits = 2048
66+
rawKey, err := rsa.GenerateKey(rand.Reader, rsaBits)
67+
if err != nil {
68+
return nil, fmt.Errorf("failed to generate RSA key: %w", err)
69+
}
70+
key, err := jwk.FromRaw(rawKey)
71+
if err != nil {
72+
return nil, fmt.Errorf("failed to create JWK from RSA key: %w", err)
73+
}
74+
if err := key.Set(jwk.AlgorithmKey, alg); err != nil {
75+
return nil, fmt.Errorf("failed to set algorithm on RSA JWK: %w", err)
76+
}
77+
return key, nil
78+
}
79+
80+
// loadDPoPKeyFromPEM parses a PEM-encoded private key and returns it as a jwk.Key.
81+
// The DPoP algorithm is inferred from the key type:
82+
// - EC P-256 → ES256, P-384 → ES384, P-521 → ES512
83+
// - RSA → RS256
84+
func loadDPoPKeyFromPEM(pemBytes []byte) (jwk.Key, error) {
85+
key, err := jwk.ParseKey(pemBytes, jwk.WithPEM(true))
86+
if err != nil {
87+
return nil, fmt.Errorf("failed to parse DPoP key PEM: %w", err)
88+
}
89+
90+
// Infer algorithm when not already set in the PEM
91+
if key.Algorithm() == jwa.NoSignature || key.Algorithm().String() == "" {
92+
alg, err := inferDPoPAlgorithm(key)
93+
if err != nil {
94+
return nil, err
95+
}
96+
if err := key.Set(jwk.AlgorithmKey, alg); err != nil {
97+
return nil, fmt.Errorf("failed to set inferred algorithm on DPoP JWK: %w", err)
98+
}
99+
}
100+
101+
return key, nil
102+
}
103+
104+
func inferDPoPAlgorithm(key jwk.Key) (jwa.SignatureAlgorithm, error) {
105+
switch key.KeyType() { //nolint:exhaustive // only EC and RSA are valid for DPoP (RFC 9449 §4.2)
106+
case jwa.EC:
107+
var rawKey ecdsa.PrivateKey
108+
if err := key.Raw(&rawKey); err != nil {
109+
return "", fmt.Errorf("failed to get raw EC key for algorithm inference: %w", err)
110+
}
111+
switch rawKey.Curve {
112+
case elliptic.P256():
113+
return jwa.ES256, nil
114+
case elliptic.P384():
115+
return jwa.ES384, nil
116+
case elliptic.P521():
117+
return jwa.ES512, nil
118+
default:
119+
return "", errors.New("unsupported EC curve for DPoP")
120+
}
121+
case jwa.RSA:
122+
return jwa.RS256, nil
123+
default:
124+
return "", fmt.Errorf("unsupported key type %q for DPoP; only EC and RSA keys are supported", key.KeyType())
125+
}
126+
}
127+
128+
// resolveDPoPKey returns the jwk.Key to use for DPoP based on the config.
129+
// Priority: dpopJWK (already set/cached) → dpopKeyPEM (load from PEM) → dpopAlgorithm (generate).
130+
// The resolved key is cached in c.dpopJWK after first resolution.
131+
// Returns (nil, nil) when no custom DPoP key is configured; callers fall back to auto-generated RSA.
132+
//
133+
//nolint:nilnil // nil key signals "use auto-generated RSA path" — not an error condition
134+
func resolveDPoPKey(c *config) (jwk.Key, error) {
135+
if c.dpopJWK != nil {
136+
return c.dpopJWK, nil
137+
}
138+
if len(c.dpopKeyPEM) > 0 { //nolint:nestif // linear priority chain with nested error handling — complexity is inherent
139+
key, err := loadDPoPKeyFromPEM(c.dpopKeyPEM)
140+
if err != nil {
141+
return nil, fmt.Errorf("failed to load DPoP key from PEM: %w", err)
142+
}
143+
if c.dpopAlgorithm != "" {
144+
var algVal jwa.SignatureAlgorithm
145+
if err := algVal.Accept(c.dpopAlgorithm); err != nil {
146+
return nil, fmt.Errorf("invalid DPoP algorithm override %q: %w", c.dpopAlgorithm, err)
147+
}
148+
if err := key.Set(jwk.AlgorithmKey, algVal); err != nil {
149+
return nil, fmt.Errorf("failed to apply DPoP algorithm override: %w", err)
150+
}
151+
}
152+
c.dpopJWK = key
153+
return key, nil
154+
}
155+
if c.dpopAlgorithm != "" {
156+
key, err := generateDPoPKeyForAlg(c.dpopAlgorithm)
157+
if err != nil {
158+
return nil, fmt.Errorf("failed to generate DPoP key: %w", err)
159+
}
160+
c.dpopJWK = key
161+
return key, nil
162+
}
163+
return nil, nil
164+
}

0 commit comments

Comments
 (0)