diff --git a/sdk/chunked_options.go b/sdk/chunked_options.go index 80f8a78651..0a8385be0f 100644 --- a/sdk/chunked_options.go +++ b/sdk/chunked_options.go @@ -1,6 +1,7 @@ package sdk import ( + "fmt" "io" "github.com/opentdf/platform/protocol/go/policy" @@ -132,6 +133,12 @@ func WithChunkedEncryptedMetadata(metadata string) ChunkedFinalizeOption { // WithChunkedExcludeVersion omits the schemaVersion field from the // produced manifest for compatibility with older readers. +// +// A reader treats a missing schemaVersion as "predates 4.3.0" and so +// expects hex-then-base64 signatures. Those are written during +// WriteSegment, before this option is seen, so on its own this option +// makes Finalize fail with [ErrChunkedVersionHexMismatch]. Pass +// [WithChunkedTargetMode] at construction instead; it sets both. func WithChunkedExcludeVersion() ChunkedFinalizeOption { return func(c *ChunkedFinalizeConfig) error { c.excludeVersion = true @@ -139,6 +146,34 @@ func WithChunkedExcludeVersion() ChunkedFinalizeOption { } } +// WithChunkedTargetMode targets a specific TDF spec version, given as +// a semver string such as "4.2.2". +// +// Below 4.3.0 the writer emits the legacy wire format: segment, root, +// and assertion signatures are hex-encoded before base64 (yielding the +// doubly-encoded values pre-4.3.0 readers expect), and schemaVersion is +// omitted from the manifest, which is how those readers detect it. The +// two travel together -- a manifest carrying one without the other +// cannot be verified by any reader. +// +// An empty mode selects the current format. +func WithChunkedTargetMode(mode string) ChunkedWriterOption { + return func(c *ChunkedWriterConfig) error { + if mode == "" { + c.useHex = false + c.excludeVersion = false + return nil + } + legacy, err := isLessThanSemver(mode, hexSemverThreshold) + if err != nil { + return fmt.Errorf("target mode %q: %w", mode, err) + } + c.useHex = legacy + c.excludeVersion = legacy + return nil + } +} + // WithChunkedMimeType records the payload MIME type in the manifest. func WithChunkedMimeType(mimeType string) ChunkedFinalizeOption { return func(c *ChunkedFinalizeConfig) error { diff --git a/sdk/chunked_test.go b/sdk/chunked_test.go index 3c46e6ef45..67b5cffcaf 100644 --- a/sdk/chunked_test.go +++ b/sdk/chunked_test.go @@ -4,6 +4,9 @@ import ( "archive/zip" "bytes" "context" + "crypto/ecdsa" + "crypto/x509" + "encoding/pem" "errors" "fmt" "io" @@ -423,7 +426,7 @@ func (k *chunkedFakeKAS) Rewrap(_ context.Context, in *connect.Request[kaspb.Rew policyResult := &kaspb.PolicyRewrapResult{PolicyId: req.GetPolicy().GetId()} for _, kaoReq := range req.GetKeyAccessObjects() { kao := kaoReq.GetKeyAccessObject() - if kao.GetKeyType() != "wrapped" { + if kao.GetKeyType() != kWrapped { return nil, fmt.Errorf("unsupported key type %q", kao.GetKeyType()) } share, err := dec.Decrypt(kao.GetWrappedKey()) @@ -469,3 +472,224 @@ func newChunkedTestSDK(t *testing.T, _ *chunkedFakeKAS) SDK { tokenSource: ats, } } + +// TestChunkedKAOShape pins the key access object fields the chunked +// writer emits, so they cannot silently drift from the ones +// SDK.CreateTDF produces via the shared createKeyAccess helper. +func TestChunkedKAOShape(t *testing.T) { + ctx := context.Background() + kasBundle := newChunkedFakeKAS(t) + defer kasBundle.server.Close() + + s := newChunkedTestSDK(t, kasBundle) + writer, err := s.NewChunkedWriter(ctx, WithChunkedDefaultKAS(kasBundle.simpleKey())) + require.NoError(t, err) + + writeChunkedSegments(ctx, t, writer, [][]byte{[]byte("payload")}) + fin, err := writer.Finalize(ctx, WithChunkedEncryptedMetadata("meta")) + require.NoError(t, err) + require.Len(t, fin.Manifest.KeyAccessObjs, 1) + + kao := fin.Manifest.KeyAccessObjs[0] + assert.Equal(t, kWrapped, kao.KeyType) + assert.Equal(t, kKasProtocol, kao.Protocol) + assert.Equal(t, keyAccessSchemaVersion, kao.SchemaVersion) + assert.Equal(t, kasBundle.url, kao.KasURL) + assert.Equal(t, kasBundle.kid, kao.KID) + assert.NotEmpty(t, kao.WrappedKey) + assert.NotEmpty(t, kao.EncryptedMetadata) + + binding, ok := kao.PolicyBinding.(PolicyBinding) + require.True(t, ok, "policy binding should be a PolicyBinding, got %T", kao.PolicyBinding) + assert.Equal(t, hmacIntegrityAlgorithm, binding.Alg) + assert.NotEmpty(t, binding.Hash) +} + +// TestChunkedECKeyAccess covers the EC wrapping path, which the +// round-trip tests miss because the fake KAS is RSA-only. It asserts +// the manifest key type is the one the real KAS dispatches on +// ("ec-wrapped", not "eccWrapped") and that the wrapped key actually +// decrypts under the KAS private key using the AES-GCM envelope the +// KAS rewrap path expects. +func TestChunkedECKeyAccess(t *testing.T) { + pair, err := ocrypto.NewECKeyPair(ocrypto.ECCModeSecp256r1) + require.NoError(t, err) + pubPEM, err := pair.PublicKeyInPemFormat() + require.NoError(t, err) + privPEM, err := pair.PrivateKeyInPemFormat() + require.NoError(t, err) + + const kasURL = "https://kas.example.com" + dek := make([]byte, kKeySize) + for i := range dek { + dek[i] = byte(i) + } + splits := &SplitResult{ + KASPublicKeys: map[string]KASPublicKey{ + kasURL: { + Algorithm: string(ocrypto.EC256Key), + KID: "ec-kid", + PEM: pubPEM, + URL: kasURL, + }, + }, + Splits: []Split{{Data: dek, KASURLs: []string{kasURL}}}, + } + + kaos, err := buildChunkedKeyAccessObjects(splits, []byte(`{"uuid":"test"}`), "") + require.NoError(t, err) + require.Len(t, kaos, 1) + + kao := kaos[0] + assert.Equal(t, kECWrapped, kao.KeyType, "KAS dispatches on this exact string") + require.NotEmpty(t, kao.EphemeralPublicKey) + + // Unwrap the way service/kas/access/rewrap.go does for "ec-wrapped". + keySize, err := ocrypto.GetECKeySize([]byte(kao.EphemeralPublicKey)) + require.NoError(t, err) + mode, err := ocrypto.ECSizeToMode(keySize) + require.NoError(t, err) + + block, _ := pem.Decode([]byte(kao.EphemeralPublicKey)) + require.NotNil(t, block) + pub, err := x509.ParsePKIXPublicKey(block.Bytes) + require.NoError(t, err) + ecPub, ok := pub.(*ecdsa.PublicKey) + require.True(t, ok) + compressed, err := ocrypto.CompressedECPublicKey(mode, *ecPub) + require.NoError(t, err) + + priv, err := ocrypto.ECPrivateKeyFromPem([]byte(privPEM)) + require.NoError(t, err) + dec, err := ocrypto.NewSaltedECDecryptor(priv, tdfSalt(), nil) + require.NoError(t, err) + + wrapped, err := ocrypto.Base64Decode([]byte(kao.WrappedKey)) + require.NoError(t, err) + unwrapped, err := dec.DecryptWithEphemeralKey(wrapped, compressed) + require.NoError(t, err, "KAS must be able to unwrap the EC-wrapped DEK") + assert.Equal(t, dek, unwrapped) +} + +// TestChunkedLegacyTargetMode verifies that a pre-4.3.0 target mode +// produces the doubly-encoded (hex-then-base64) signatures that legacy +// readers require, and that the mainline reader -- which infers legacy +// mode solely from a missing schemaVersion -- still round-trips it. +func TestChunkedLegacyTargetMode(t *testing.T) { + ctx := context.Background() + kasBundle := newChunkedFakeKAS(t) + defer kasBundle.server.Close() + + s := newChunkedTestSDK(t, kasBundle) + + writer, err := s.NewChunkedWriter(ctx, + WithChunkedDefaultKAS(kasBundle.simpleKey()), + WithChunkedTargetMode("4.2.2"), + ) + require.NoError(t, err) + + body := writeChunkedSegments(ctx, t, writer, [][]byte{ + []byte("legacy "), []byte("hex "), []byte("payload"), + }) + fin, err := writer.Finalize(ctx) + require.NoError(t, err) + + // Absence of schemaVersion is the pre-4.3.0 marker readers key on. + assert.Empty(t, fin.Manifest.TDFVersion, "legacy manifest must omit schemaVersion") + + // A legacy HS256 signature is base64(hex(hmac)): 32 HMAC bytes + // rendered as 64 hex characters. The 4.3.0 form is base64(hmac), + // which decodes to 32 bytes. + rootSig, err := ocrypto.Base64Decode([]byte(fin.Manifest.Signature)) + require.NoError(t, err) + assert.Len(t, rootSig, 64, "root signature must be hex-encoded before base64") + + for i, seg := range fin.Manifest.Segments { + segSig, err := ocrypto.Base64Decode([]byte(seg.Hash)) + require.NoError(t, err) + assert.Lenf(t, segSig, 64, "segment %d hash must be hex-encoded before base64", i) + } + + tdfBytes := bytes.Join([][]byte{body, fin.Data}, nil) + reader, err := s.LoadTDF(bytes.NewReader(tdfBytes), + WithKasAllowlist([]string{kasBundle.url}), + ) + require.NoError(t, err) + + plain, err := io.ReadAll(reader) + require.NoError(t, err) + assert.Equal(t, []byte("legacy hex payload"), plain) +} + +// TestChunkedCurrentTargetMode pins the 4.3.0-and-later form so a +// regression in either direction is caught. +func TestChunkedCurrentTargetMode(t *testing.T) { + ctx := context.Background() + kasBundle := newChunkedFakeKAS(t) + defer kasBundle.server.Close() + + s := newChunkedTestSDK(t, kasBundle) + + writer, err := s.NewChunkedWriter(ctx, + WithChunkedDefaultKAS(kasBundle.simpleKey()), + WithChunkedTargetMode("4.3.0"), + ) + require.NoError(t, err) + + body := writeChunkedSegments(ctx, t, writer, [][]byte{[]byte("current")}) + fin, err := writer.Finalize(ctx) + require.NoError(t, err) + + assert.Equal(t, TDFSpecVersion, fin.Manifest.TDFVersion) + + rootSig, err := ocrypto.Base64Decode([]byte(fin.Manifest.Signature)) + require.NoError(t, err) + assert.Len(t, rootSig, 32, "root signature must be the raw HMAC, not hex") + + tdfBytes := bytes.Join([][]byte{body, fin.Data}, nil) + reader, err := s.LoadTDF(bytes.NewReader(tdfBytes), + WithKasAllowlist([]string{kasBundle.url}), + ) + require.NoError(t, err) + plain, err := io.ReadAll(reader) + require.NoError(t, err) + assert.Equal(t, []byte("current"), plain) +} + +// TestChunkedExcludeVersionRequiresLegacyMode verifies that omitting +// schemaVersion without the matching signature encoding is refused +// rather than silently producing an unverifiable TDF. +func TestChunkedExcludeVersionRequiresLegacyMode(t *testing.T) { + ctx := context.Background() + kasBundle := newChunkedFakeKAS(t) + defer kasBundle.server.Close() + + s := newChunkedTestSDK(t, kasBundle) + + writer, err := s.NewChunkedWriter(ctx, + WithChunkedDefaultKAS(kasBundle.simpleKey()), + ) + require.NoError(t, err) + + writeChunkedSegments(ctx, t, writer, [][]byte{[]byte("mismatch")}) + + _, err = writer.Finalize(ctx, WithChunkedExcludeVersion()) + require.ErrorIs(t, err, ErrChunkedVersionHexMismatch) +} + +// TestChunkedTargetModeInvalid rejects a non-semver target mode at +// construction rather than at Finalize. +func TestChunkedTargetModeInvalid(t *testing.T) { + ctx := context.Background() + kasBundle := newChunkedFakeKAS(t) + defer kasBundle.server.Close() + + s := newChunkedTestSDK(t, kasBundle) + + _, err := s.NewChunkedWriter(ctx, + WithChunkedDefaultKAS(kasBundle.simpleKey()), + WithChunkedTargetMode("not-a-version"), + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "not-a-version") +} diff --git a/sdk/chunked_writer.go b/sdk/chunked_writer.go index d56c8744f9..2e6378aca9 100644 --- a/sdk/chunked_writer.go +++ b/sdk/chunked_writer.go @@ -3,8 +3,6 @@ package sdk import ( "bytes" "context" - "crypto/sha256" - "encoding/hex" "encoding/json" "errors" "fmt" @@ -33,6 +31,11 @@ var ( // receives an index that was already written. ErrChunkedSegmentAlreadyWritten = errors.New("chunked: segment already written") + // ErrChunkedVersionHexMismatch is returned when Finalize is asked + // to omit schemaVersion on a writer that was not constructed in + // legacy signature mode. Use WithChunkedTargetMode to set both. + ErrChunkedVersionHexMismatch = errors.New("chunked: excluding schemaVersion requires a pre-4.3.0 target mode; use WithChunkedTargetMode") + // ErrChunkedAssertionsUnsupported is returned when Finalize // receives assertions but signing them is not yet implemented. ErrChunkedAssertionsUnsupported = errors.New("chunked: assertions not supported by ChunkedWriter; use SDK.CreateTDF") @@ -128,6 +131,12 @@ type ChunkedWriterConfig struct { // FixedClock for deterministic ZIP output. clock Clock + // excludeVersion omits the schemaVersion field from the manifest. + // Set together with useHex by WithChunkedTargetMode; readers use + // the field's absence as the pre-4.3.0 marker, so the two must + // agree. + excludeVersion bool + // initialAttributes are the attribute values used at Finalize // when the Finalize call does not supply its own. initialAttributes []*policy.Value @@ -151,6 +160,11 @@ type ChunkedWriterConfig struct { // splitter maps attribute values to DEK splits at Finalize time. // Defaults to DefaultKeySplitter (single-KAS only). splitter KeySplitter + + // useHex hex-encodes segment, root, and assertion signatures + // before base64, producing the doubly-encoded form that readers + // older than 4.3.0 require. Set by WithChunkedTargetMode. + useHex bool } // ChunkedFinalizeConfig captures Finalize-time overrides. @@ -173,7 +187,8 @@ type ChunkedFinalizeConfig struct { encryptedMetadata string // excludeVersion omits the schemaVersion field from the manifest - // for compatibility with older readers. + // for compatibility with older readers. Defaults to the writer's + // setting; see WithChunkedTargetMode. excludeVersion bool // keepSegments restricts the finalized manifest to a contiguous @@ -208,6 +223,10 @@ type chunkedWriter struct { // dek is the Data Encryption Key. 32 bytes (AES-256). dek []byte + // excludeVersion omits schemaVersion from the manifest unless a + // Finalize option overrides it. + excludeVersion bool + // finalized is true once Finalize returns successfully. finalized bool @@ -241,6 +260,11 @@ type chunkedWriter struct { // splitter converts attributes + DEK into key splits at // Finalize time. splitter KeySplitter + + // useHex selects the pre-4.3.0 doubly-encoded signature form. + // Read by WriteSegment, so it is fixed at construction rather + // than at Finalize. + useHex bool } // NewChunkedWriter constructs a per-segment TDF writer. The returned @@ -275,12 +299,14 @@ func (s SDK) NewChunkedWriter(_ context.Context, opts ...ChunkedWriterOption) (C block: block, clock: cfg.clock, dek: dek, + excludeVersion: cfg.excludeVersion, initialAttributes: cfg.initialAttributes, initialDefaultKAS: cfg.initialDefaultKAS, integrityAlgorithm: cfg.integrityAlgorithm, segmentIntegrityAlgorithm: cfg.segmentIntegrityAlgorithm, segments: make(map[int]*Segment), splitter: cfg.splitter, + useHex: cfg.useHex, }, nil } @@ -376,7 +402,7 @@ func (w *chunkedWriter) WriteSegment(ctx context.Context, index int, data []byte sealed := make([]byte, 0, len(nonce)+len(ciphertext)) sealed = append(sealed, nonce...) sealed = append(sealed, ciphertext...) - sig, err := calculateSignature(sealed, w.dek, w.segmentIntegrityAlgorithm, false) + sig, err := calculateSignature(sealed, w.dek, w.segmentIntegrityAlgorithm, w.useHex) if err != nil { return nil, fmt.Errorf("segment %d signature: %w", index, err) } @@ -420,6 +446,7 @@ func (w *chunkedWriter) applyFinalizeOptions(opts []ChunkedFinalizeOption) (*Chu cfg := &ChunkedFinalizeConfig{ attributes: nil, encryptedMetadata: "", + excludeVersion: w.excludeVersion, mimeType: "application/octet-stream", } for _, opt := range opts { @@ -427,6 +454,14 @@ func (w *chunkedWriter) applyFinalizeOptions(opts []ChunkedFinalizeOption) (*Chu return nil, err } } + // Omitting schemaVersion is how a reader is told the TDF predates + // 4.3.0, and such a reader expects hex-then-base64 signatures. The + // segment signatures were already written by then, so the two + // settings cannot be reconciled here -- refuse rather than emit a + // TDF that no reader can verify. + if cfg.excludeVersion && !w.useHex { + return nil, ErrChunkedVersionHexMismatch + } if len(cfg.attributes) == 0 && len(w.initialAttributes) > 0 { cfg.attributes = w.initialAttributes } @@ -497,7 +532,7 @@ func (w *chunkedWriter) buildManifest(ctx context.Context, cfg *ChunkedFinalizeC } } - rootSig, err := calculateSignature(aggregate.Bytes(), w.dek, w.integrityAlgorithm, false) + rootSig, err := calculateSignature(aggregate.Bytes(), w.dek, w.integrityAlgorithm, w.useHex) if err != nil { return nil, 0, 0, err } @@ -560,38 +595,35 @@ func buildChunkedKeyAccessObjects(splits *SplitResult, policyBytes []byte, metad if splits == nil || len(splits.Splits) == 0 { return nil, errors.New("no splits produced") } - base64Policy := string(ocrypto.Base64Encode(policyBytes)) + base64Policy := ocrypto.Base64Encode(policyBytes) var out []KeyAccess for _, split := range splits.Splits { + // Policy binding and metadata are keyed on the split share, not + // on the KAS, so compute them once per split rather than once + // per KAS URL in an OR-group. + policyBinding := createPolicyBinding(split.Data, base64Policy) + var encMeta string + if metadata != "" { + m, err := encryptMetadata(split.Data, metadata) + if err != nil { + return nil, fmt.Errorf("encrypt metadata for split %s: %w", split.ID, err) + } + encMeta = m + } for _, url := range split.KASURLs { pk, ok := splits.KASPublicKeys[url] if !ok { continue } - var encMeta string - if metadata != "" { - m, err := chunkedEncryptMetadata(split.Data, metadata) - if err != nil { - return nil, fmt.Errorf("encrypt metadata for %s: %w", url, err) - } - encMeta = m + if pk.PEM == "" { + return nil, fmt.Errorf("splitID:[%s], kas:[%s]: %w", split.ID, url, errKasPubKeyMissing) } - wrappedKey, keyType, ephemeralPub, err := chunkedWrapKeyWithPublicKey(split.Data, pk) + kao, err := createKeyAccess(pk.toKASInfo(), split.Data, policyBinding, encMeta, split.ID) if err != nil { return nil, fmt.Errorf("wrap key for %s: %w", url, err) } - out = append(out, KeyAccess{ - EncryptedMetadata: encMeta, - EphemeralPublicKey: ephemeralPub, - KasURL: url, - KeyType: keyType, - KID: pk.KID, - PolicyBinding: chunkedCreatePolicyBinding(split.Data, base64Policy), - Protocol: "kas", - SplitID: split.ID, - WrappedKey: wrappedKey, - }) + out = append(out, kao) } } if len(out) == 0 { @@ -631,135 +663,3 @@ func cloneChunkedManifest(in *Manifest) *Manifest { } return &out } - -// integrityAlgorithmString maps an IntegrityAlgorithm to its manifest -// string form. -func integrityAlgorithmString(a IntegrityAlgorithm) string { - switch a { - case GMAC: - return gmacIntegrityAlgorithm - default: - return hmacIntegrityAlgorithm - } -} - -// chunkedCreatePolicyBinding produces an HMAC-SHA256 binding value -// keyed on the split share, over the base64-encoded policy. -func chunkedCreatePolicyBinding(symKey []byte, base64Policy string) any { - mac := ocrypto.CalculateSHA256Hmac(symKey, []byte(base64Policy)) - hashHex := hex.EncodeToString(mac) - return PolicyBinding{ - Alg: hmacIntegrityAlgorithm, - Hash: string(ocrypto.Base64Encode([]byte(hashHex))), - } -} - -// chunkedEncryptMetadata wraps opaque metadata with AES-GCM keyed on -// the split share, returning a base64-encoded EncryptedMetadata JSON -// blob suitable for the KAO's encryptedMetadata field. -func chunkedEncryptMetadata(symKey []byte, metadata string) (string, error) { - gcm, err := ocrypto.NewAESGcm(symKey) - if err != nil { - return "", fmt.Errorf("aes-gcm: %w", err) - } - sealed, err := gcm.Encrypt([]byte(metadata)) - if err != nil { - return "", fmt.Errorf("encrypt: %w", err) - } - iv := sealed[:ocrypto.GcmStandardNonceSize] - blob, err := json.Marshal(EncryptedMetadata{ - Cipher: string(ocrypto.Base64Encode(sealed)), - Iv: string(ocrypto.Base64Encode(iv)), - }) - if err != nil { - return "", fmt.Errorf("marshal: %w", err) - } - return string(ocrypto.Base64Encode(blob)), nil -} - -// chunkedWrapKeyWithEC wraps symKey using an ECIES-style envelope: -// derive a wrapping key from ECDH(ephemeral, kas) via HKDF and -// XOR-wrap. -func chunkedWrapKeyWithEC(keyType ocrypto.KeyType, kasPubPEM string, symKey []byte) (string, string, string, error) { - mode, err := ocrypto.ECKeyTypeToMode(keyType) - if err != nil { - return "", "", "", fmt.Errorf("ec key type mode: %w", err) - } - pair, err := ocrypto.NewECKeyPair(mode) - if err != nil { - return "", "", "", fmt.Errorf("ec keypair: %w", err) - } - ephemeralPub, err := pair.PublicKeyInPemFormat() - if err != nil { - return "", "", "", fmt.Errorf("ephemeral pub: %w", err) - } - ephemeralPriv, err := pair.PrivateKeyInPemFormat() - if err != nil { - return "", "", "", fmt.Errorf("ephemeral priv: %w", err) - } - shared, err := ocrypto.ComputeECDHKey([]byte(ephemeralPriv), []byte(kasPubPEM)) - if err != nil { - return "", "", "", fmt.Errorf("ecdh: %w", err) - } - salt := sha256.Sum256([]byte("TDF")) - wrapKey, err := ocrypto.CalculateHKDF(salt[:], shared) - if err != nil { - return "", "", "", fmt.Errorf("hkdf: %w", err) - } - switch { - case len(wrapKey) > len(symKey): - wrapKey = wrapKey[:len(symKey)] - case len(wrapKey) < len(symKey): - return "", "", "", fmt.Errorf("wrap key too short: got %d expected %d", len(wrapKey), len(symKey)) - } - sealed := make([]byte, len(symKey)) - for i := range symKey { - sealed[i] = symKey[i] ^ wrapKey[i] - } - return string(ocrypto.Base64Encode(sealed)), "eccWrapped", ephemeralPub, nil -} - -// chunkedWrapKeyWithKEM wraps a DEK share with any KEM scheme (ML-KEM -// or hybrid). -func chunkedWrapKeyWithKEM(keyType ocrypto.KeyType, kasPubPEM string, symKey []byte) (string, string, string, error) { - envelope, err := ocrypto.WrapDEK(keyType, kasPubPEM, symKey) - if err != nil { - return "", "", "", fmt.Errorf("kem wrap: %w", err) - } - scheme := "hybrid-wrapped" - if ocrypto.IsMLKEMKeyType(keyType) { - scheme = "mlkem-wrapped" - } - return string(ocrypto.Base64Encode(envelope)), scheme, "", nil -} - -// chunkedWrapKeyWithRSA wraps symKey using RSA-OAEP against the KAS -// public key. -func chunkedWrapKeyWithRSA(kasPubPEM string, symKey []byte) (string, string, string, error) { - enc, err := ocrypto.FromPublicPEM(kasPubPEM) - if err != nil { - return "", "", "", fmt.Errorf("rsa encryptor: %w", err) - } - sealed, err := enc.Encrypt(symKey) - if err != nil { - return "", "", "", fmt.Errorf("rsa encrypt: %w", err) - } - return string(ocrypto.Base64Encode(sealed)), kWrapped, "", nil -} - -// chunkedWrapKeyWithPublicKey dispatches to the right KAS wrapping -// scheme based on the algorithm advertised by the splitter. -func chunkedWrapKeyWithPublicKey(symKey []byte, pk KASPublicKey) (string, string, string, error) { - if pk.PEM == "" { - return "", "", "", fmt.Errorf("public key PEM is empty for kas %s", pk.URL) - } - ktype := ocrypto.KeyType(pk.Algorithm) - switch { - case ocrypto.IsKEMKeyType(ktype): - return chunkedWrapKeyWithKEM(ktype, pk.PEM, symKey) - case ocrypto.IsECKeyType(ktype): - return chunkedWrapKeyWithEC(ktype, pk.PEM, symKey) - default: - return chunkedWrapKeyWithRSA(pk.PEM, symKey) - } -} diff --git a/sdk/experimental/tdf/assertion.go b/sdk/experimental/tdf/assertion.go index db417a5479..b008cf543a 100644 --- a/sdk/experimental/tdf/assertion.go +++ b/sdk/experimental/tdf/assertion.go @@ -3,422 +3,126 @@ package tdf import ( - "encoding/json" - "errors" - "fmt" - - "github.com/gowebpki/jcs" - "github.com/lestrrat-go/jwx/v2/cert" - "github.com/lestrrat-go/jwx/v2/jwa" - "github.com/lestrrat-go/jwx/v2/jws" - "github.com/lestrrat-go/jwx/v2/jwt" - "github.com/opentdf/platform/lib/ocrypto" + "github.com/opentdf/platform/sdk" ) const ( - // SystemMetadataAssertionID is the standard ID for system metadata assertions - SystemMetadataAssertionID = "system-metadata" - // SystemMetadataSchemaV1 defines the schema version for system metadata - SystemMetadataSchemaV1 = "system-metadata-v1" // kAssertionSignature is the JWT claim key for assertion signatures kAssertionSignature = "assertionSig" // kAssertionHash is the JWT claim key for assertion hashes kAssertionHash = "assertionHash" ) -// AssertionConfig defines an assertion to be included in the TDF during creation. -// -// AssertionConfig extends Assertion with a signing key, enabling creation -// of cryptographically signed assertions. The signing key is used during -// TDF creation but is not stored in the final TDF. -// -// Required fields: -// - ID: Unique identifier for the assertion -// - Type: The kind of assertion (BaseAssertion, HandlingAssertion) -// - Scope: What the assertion applies to (PayloadScope, TrustedDataObjScope) -// - AppliesToState: When the assertion is relevant (Encrypted, Unencrypted) -// - Statement: The assertion content and metadata -// -// Optional fields: -// - SigningKey: Custom signing key (defaults to DEK with HS256) -// -// Example: -// -// assertion := AssertionConfig{ -// ID: "retention-policy", -// Type: HandlingAssertion, -// Scope: PayloadScope, -// AppliesToState: Unencrypted, -// Statement: Statement{ -// Format: "json", -// Schema: "retention-v1", -// Value: `{"retain_days": 90, "auto_delete": true}`, -// }, -// } -type AssertionConfig struct { - ID string `validate:"required"` - Type AssertionType `validate:"required"` - Scope Scope `validate:"required"` - AppliesToState AppliesToState `validate:"required"` - Statement Statement - SigningKey AssertionKey -} - -// Assertion represents a cryptographically signed assertion in the TDF manifest. -// -// Assertions provide integrity verification and handling instructions that are -// cryptographically bound to the TDF. They cannot be modified or copied to -// another TDF without detection due to the cryptographic binding. -// -// The assertion structure includes: -// - Metadata: ID, type, scope, and state applicability -// - Statement: The actual assertion content in structured format -// - Binding: Cryptographic signature ensuring integrity -// -// Assertions are verified during TDF reading to ensure they haven't been -// tampered with since TDF creation. -type Assertion struct { - ID string `json:"id"` - Type AssertionType `json:"type"` - Scope Scope `json:"scope"` - AppliesToState AppliesToState `json:"appliesToState,omitempty"` - Statement Statement `json:"statement"` - Binding Binding `json:"binding,omitempty"` -} - -var errAssertionVerifyKeyFailure = errors.New("assertion: failed to verify with provided key") - -// Sign signs the assertion with the given hash and signature using the key. -// It returns an error if the signing fails. -// The assertion binding is updated with the method and the signature. -func (a *Assertion) Sign(hash, sig string, key AssertionKey) error { - tok := jwt.New() - if err := tok.Set(kAssertionHash, hash); err != nil { - return fmt.Errorf("failed to set assertion hash: %w", err) - } - if err := tok.Set(kAssertionSignature, sig); err != nil { - return fmt.Errorf("failed to set assertion signature: %w", err) - } - - // sign the hash and signature - signedTok, err := jwt.Sign(tok, jwt.WithKey(jwa.KeyAlgorithmFrom(key.Alg.String()), key.Key)) - if err != nil { - return fmt.Errorf("signing assertion failed: %w", err) - } - - // set the binding - a.Binding.Method = JWS.String() - a.Binding.Signature = string(signedTok) - - return nil -} - -// Verify checks the binding signature of the assertion and -// returns the hash and the signature. It returns an error if the verification fails. -func (a Assertion) Verify(key AssertionKey) (string, string, error) { - parseOptions := []jwt.ParseOption{ - jwt.WithKey(jwa.KeyAlgorithmFrom(key.Alg.String()), key.Key), - } - - // Try to get a key from the JWS protected header if it exists - if decodedSig, err := jws.Parse([]byte(a.Binding.Signature)); err == nil && len(decodedSig.Signatures()) > 0 { //nolint:nestif // many keys - sig := decodedSig.Signatures()[0] - // First, try to get a JWK from the header - if jwkKey := sig.ProtectedHeaders().JWK(); jwkKey != nil { - var rawKey interface{} - if err := jwkKey.Raw(&rawKey); err == nil { - parseOptions = append(parseOptions, jwt.WithKey(sig.ProtectedHeaders().Algorithm(), rawKey)) - } - } - // Also try to get a key from x5c (certificate chain) header - if x5cChain := sig.ProtectedHeaders().X509CertChain(); x5cChain != nil && x5cChain.Len() > 0 { - // Get the first certificate (the signing certificate) - certBytes, ok := x5cChain.Get(0) - if ok { - // Parse the certificate to extract the public key - x509Cert, err := cert.Parse(certBytes) - if err == nil && x509Cert != nil { - parseOptions = append(parseOptions, jwt.WithKey(sig.ProtectedHeaders().Algorithm(), x509Cert.PublicKey)) - } - } - } - } - - tok, err := jwt.Parse([]byte(a.Binding.Signature), parseOptions...) - if err != nil { - return "", "", fmt.Errorf("%w: %w", errAssertionVerifyKeyFailure, err) - } - hashClaim, found := tok.Get(kAssertionHash) - if !found { - return "", "", errors.New("hash claim not found") - } - hash, ok := hashClaim.(string) - if !ok { - return "", "", errors.New("hash claim is not a string") - } - - sigClaim, found := tok.Get(kAssertionSignature) - if !found { - return "", "", errors.New("signature claim not found") - } - sig, ok := sigClaim.(string) - if !ok { - return "", "", errors.New("signature claim is not a string") - } - return hash, sig, nil -} - -// GetHash returns the hash of the assertion in hex format. -func (a Assertion) GetHash() ([]byte, error) { - // Clear out the binding - a.Binding = Binding{} - - // Marshal the assertion to JSON - assertionJSON, err := json.Marshal(a) - if err != nil { - return nil, fmt.Errorf("json.Marshal failed: %w", err) - } - - // Unmarshal the JSON into a map to manipulate it - var jsonObject map[string]interface{} - if err := json.Unmarshal(assertionJSON, &jsonObject); err != nil { - return nil, fmt.Errorf("json.Unmarshal failed: %w", err) - } - - // Remove the binding key - delete(jsonObject, "binding") - - // Marshal the map back to JSON - assertionJSON, err = json.Marshal(jsonObject) - if err != nil { - return nil, fmt.Errorf("json.Marshal failed: %w", err) - } - - // Transform the JSON using JCS - transformedJSON, err := jcs.Transform(assertionJSON) - if err != nil { - return nil, fmt.Errorf("jcs.Transform failed: %w", err) - } - - return ocrypto.SHA256AsHex(transformedJSON), nil -} - -func (s *Statement) UnmarshalJSON(data []byte) error { - // Define a custom struct for deserialization - type Alias Statement - aux := &struct { - Value json.RawMessage `json:"value,omitempty"` - *Alias - }{ - Alias: (*Alias)(s), - } - - if err := json.Unmarshal(data, &aux); err != nil { - return err - } - - // Attempt to decode Value as an object - var temp map[string]interface{} - if json.Unmarshal(aux.Value, &temp) == nil { - // Re-encode the object as a string and assign to Value - objAsString, err := json.Marshal(temp) - if err != nil { - return err - } - s.Value = string(objAsString) - } else { - // Assign raw string to Value - var str string - if err := json.Unmarshal(aux.Value, &str); err != nil { - return fmt.Errorf("value is neither a valid JSON object nor a string: %s", string(aux.Value)) - } - s.Value = str - } - - return nil -} - -// Statement includes information applying to the scope of the assertion. -// It could contain rights, handling instructions, or general metadata. -type Statement struct { - // Format describes the payload encoding format. (e.g. json) - Format string `json:"format,omitempty" validate:"required"` - // Schema describes the schema of the payload. (e.g. tdf) - Schema string `json:"schema,omitempty" validate:"required"` - // Value is the payload of the assertion. - Value string `json:"value,omitempty" validate:"required"` -} - -// Binding enforces cryptographic integrity of the assertion. -// So the can't be modified or copied to another tdf. -type Binding struct { - // Method used to bind the assertion. (e.g. jws) - Method string `json:"method,omitempty"` - // Signature of the assertion. - Signature string `json:"signature,omitempty"` -} - -// AssertionType represents the category of assertion being made. -// -// Different assertion types serve different purposes in TDF handling: -// - HandlingAssertion: Instructions for data processing, retention, deletion -// - BaseAssertion: General-purpose assertions including metadata, audit info -type AssertionType string - -const ( - // HandlingAssertion provides instructions for data handling and processing. - // Examples: retention policies, deletion schedules, processing requirements - HandlingAssertion AssertionType = "handling" - // BaseAssertion is a general-purpose assertion type for metadata and other content. - // Examples: audit information, system metadata, custom business logic - BaseAssertion AssertionType = "other" +// The assertion types below are aliases onto their [github.com/opentdf/platform/sdk] +// counterparts, which own the implementation. They are kept here so that +// existing importers of this experimental package continue to compile +// unchanged, and so that assertions produced here interoperate directly with +// the stable SDK. Prefer the sdk-scoped names in new code. +type ( + // AssertionConfig defines an assertion to be included in the TDF during + // creation. It extends [Assertion] with a signing key, which is used + // during TDF creation but is not stored in the final TDF. + // + // See [sdk.AssertionConfig]. + AssertionConfig = sdk.AssertionConfig + + // Assertion represents a cryptographically signed assertion in the TDF + // manifest. Assertions provide integrity verification and handling + // instructions that are cryptographically bound to the TDF, so they + // cannot be modified or copied to another TDF without detection. + // + // See [sdk.Assertion]. + Assertion = sdk.Assertion + + // Statement includes information applying to the scope of the assertion. + // It could contain rights, handling instructions, or general metadata. + // + // See [sdk.Statement]. + Statement = sdk.Statement + + // Binding enforces cryptographic integrity of the assertion, so it + // cannot be modified or copied to another TDF. + // + // See [sdk.Binding]. + Binding = sdk.Binding + + // AssertionType represents the category of assertion being made. + // + // See [sdk.AssertionType]. + AssertionType = sdk.AssertionType + + // Scope defines what component of the TDF the assertion applies to. + // + // See [sdk.Scope]. + Scope = sdk.Scope + + // AppliesToState indicates when the assertion is relevant in the TDF + // lifecycle: before decryption (Encrypted) or after (Unencrypted). + // + // See [sdk.AppliesToState]. + AppliesToState = sdk.AppliesToState + + // BindingMethod represents the cryptographic method used to bind + // assertions to the TDF. + // + // See [sdk.BindingMethod]. + BindingMethod = sdk.BindingMethod + + // AssertionKeyAlg represents the cryptographic algorithm for assertion + // signing keys. + // + // See [sdk.AssertionKeyAlg]. + AssertionKeyAlg = sdk.AssertionKeyAlg + + // AssertionKey represents a cryptographic key for signing and verifying + // assertions. For RS256 the Key is an RSA private key (or a + // [crypto.Signer] for hardware-backed keys); for HS256 it is the shared + // secret bytes. + // + // See [sdk.AssertionKey]. + AssertionKey = sdk.AssertionKey + + // AssertionVerificationKeys represents the verification keys for + // assertions, with an optional default for unlisted assertion IDs. + // + // See [sdk.AssertionVerificationKeys]. + AssertionVerificationKeys = sdk.AssertionVerificationKeys ) -// String returns the string representation of the assertion type. -func (at AssertionType) String() string { - return string(at) -} - -// Scope defines what component of the TDF the assertion applies to. -// -// Scope determines which part of the TDF structure the assertion governs: -// - TrustedDataObjScope: Assertion applies to the entire TDF object -// - PayloadScope: Assertion applies only to the encrypted payload data -type Scope string - const ( - // TrustedDataObjScope indicates the assertion applies to the complete TDF object. - // This includes manifest, key access objects, and payload. - TrustedDataObjScope Scope = "tdo" + // SystemMetadataAssertionID is the standard ID for system metadata assertions. + SystemMetadataAssertionID = sdk.SystemMetadataAssertionID + // SystemMetadataSchemaV1 defines the schema version for system metadata. + SystemMetadataSchemaV1 = sdk.SystemMetadataSchemaV1 + + // HandlingAssertion provides instructions for data handling and processing. + // Examples: retention policies, deletion schedules, processing requirements. + HandlingAssertion = sdk.HandlingAssertion + // BaseAssertion is a general-purpose assertion type for metadata and other + // content. Examples: audit information, system metadata, custom business logic. + BaseAssertion = sdk.BaseAssertion + + // TrustedDataObjScope indicates the assertion applies to the complete TDF + // object, including manifest, key access objects, and payload. + TrustedDataObjScope = sdk.TrustedDataObjScope // PayloadScope indicates the assertion applies only to the payload data. // This is the most common scope for data handling assertions. - PayloadScope Scope = "payload" -) - -// String returns the string representation of the scope. -func (s Scope) String() string { - return string(s) -} - -// AppliesToState indicates when the assertion is relevant in the TDF lifecycle. -// -// This determines whether the assertion should be processed before or after -// decryption, enabling different handling patterns: -// - Encrypted: Process before decryption (e.g., access logging) -// - Unencrypted: Process after decryption (e.g., content filtering) -type AppliesToState string - -const ( - // Encrypted means the assertion should be processed before payload decryption. - // Used for access control, audit logging, and pre-processing requirements. - Encrypted AppliesToState = "encrypted" - // Unencrypted means the assertion should be processed after payload decryption. - // Used for content analysis, post-processing, and data handling requirements. - Unencrypted AppliesToState = "unencrypted" -) - -// String returns the string representation of the applies to state. -func (ats AppliesToState) String() string { - return string(ats) -} + PayloadScope = sdk.PayloadScope -// BindingMethod represents the cryptographic method used to bind assertions to the TDF. -// -// The binding method ensures assertions cannot be modified or transferred -// to other TDFs without detection. -type BindingMethod string + // Encrypted means the assertion should be processed before payload + // decryption. Used for access control, audit logging, and pre-processing. + Encrypted = sdk.Encrypted + // Unencrypted means the assertion should be processed after payload + // decryption. Used for content analysis and post-processing. + Unencrypted = sdk.Unencrypted -const ( // JWS (JSON Web Signature) is the standard method for assertion binding. - // Uses JWT-based cryptographic signatures for tamper detection. - JWS BindingMethod = "jws" + JWS = sdk.JWS + + // AssertionKeyAlgRS256 uses RSA-SHA256 for assertion signatures. Suitable + // when assertions must be verified without access to the signing key. + AssertionKeyAlgRS256 = sdk.AssertionKeyAlgRS256 + // AssertionKeyAlgHS256 uses HMAC-SHA256 for assertion signatures. More + // efficient, and lets the TDF's DEK double as the signing key. + AssertionKeyAlgHS256 = sdk.AssertionKeyAlgHS256 ) - -// String returns the string representation of the binding method. -func (bm BindingMethod) String() string { - return string(bm) -} - -// AssertionKeyAlg represents the cryptographic algorithm for assertion signing keys. -// -// Different algorithms provide different security and compatibility characteristics: -// - RS256: RSA-based signatures, widely supported, good for public key scenarios -// - HS256: HMAC-based signatures, simpler, good for shared key scenarios -type AssertionKeyAlg string - -const ( - // AssertionKeyAlgRS256 uses RSA-SHA256 for assertion signatures. - // Suitable when assertions need to be verified by parties without access to signing keys. - AssertionKeyAlgRS256 AssertionKeyAlg = "RS256" - // AssertionKeyAlgHS256 uses HMAC-SHA256 for assertion signatures. - // More efficient, suitable when the same key used for TDF encryption can sign assertions. - AssertionKeyAlgHS256 AssertionKeyAlg = "HS256" -) - -// String returns the string representation of the algorithm. -func (a AssertionKeyAlg) String() string { - return string(a) -} - -// AssertionKey represents a cryptographic key for signing and verifying assertions. -// -// The key can be either RSA or HMAC-based depending on the algorithm: -// - RS256: Key should be an RSA private key (*rsa.PrivateKey or jwk.Key) -// - HS256: Key should be a byte slice containing the shared secret -// -// Example usage: -// -// // HMAC key using TDF's Data Encryption Key -// hmacKey := AssertionKey{ -// Alg: AssertionKeyAlgHS256, -// Key: dek, // 32-byte AES key -// } -// -// // RSA key for public key scenarios -// rsaKey := AssertionKey{ -// Alg: AssertionKeyAlgRS256, -// Key: privateKey, // *rsa.PrivateKey -// } -type AssertionKey struct { - // Alg specifies the cryptographic algorithm for this key - Alg AssertionKeyAlg - // Key contains the actual key material (type depends on algorithm). - // Can be raw key material or a crypto.Signer for hardware-backed keys (HSM/KMS). - Key interface{} -} - -// Algorithm returns the cryptographic algorithm of the key. -func (k AssertionKey) Algorithm() AssertionKeyAlg { - return k.Alg -} - -// IsEmpty returns true if the key has no algorithm or key material configured. -// Used to check if a default signing key should be used instead. -func (k AssertionKey) IsEmpty() bool { - return k.Key == nil && k.Alg == "" -} - -// AssertionVerificationKeys represents the verification keys for assertions. -type AssertionVerificationKeys struct { - // Default key to use if the key for the assertion ID is not found. - DefaultKey AssertionKey - // Map of assertion ID to key. - Keys map[string]AssertionKey -} - -// Get returns the key for the given assertion ID or the default key if the key is not found. -// If the default key is not set, it returns an empty key. -func (k AssertionVerificationKeys) Get(assertionID string) (AssertionKey, error) { - if key, ok := k.Keys[assertionID]; ok { - return key, nil - } - if k.DefaultKey.IsEmpty() { - return AssertionKey{}, nil - } - return k.DefaultKey, nil -} - -// IsEmpty returns true if the default key and the keys map are empty. -func (k AssertionVerificationKeys) IsEmpty() bool { - return k.DefaultKey.IsEmpty() && len(k.Keys) == 0 -} diff --git a/sdk/experimental/tdf/key_access.go b/sdk/experimental/tdf/key_access.go index ad849c739c..af6c048225 100644 --- a/sdk/experimental/tdf/key_access.go +++ b/sdk/experimental/tdf/key_access.go @@ -73,7 +73,7 @@ func buildKeyAccessObjects(result *keysplit.SplitResult, policyBytes []byte, met KeyType: keyType, KasURL: kasURL, KID: pubKeyInfo.KID, - Protocol: "kas", + Protocol: kKasProtocol, SplitID: split.ID, WrappedKey: wrappedKey, PolicyBinding: policyBinding, @@ -174,7 +174,7 @@ func wrapKeyWithPublicKey(symKey []byte, pubKeyInfo keysplit.KASPublicKey) (stri } // Handle RSA key wrapping wrapped, err := wrapKeyWithRSA(pubKeyInfo.PEM, symKey) - return wrapped, "wrapped", "", err + return wrapped, kWrapped, "", err } // wrapKeyWithEC encrypts a key using EC public key with ECIES @@ -209,27 +209,27 @@ func wrapKeyWithEC(keyType ocrypto.KeyType, kasPublicKeyPEM string, symKey []byt return "", "", "", fmt.Errorf("failed to compute ECDH key: %w", err) } - // Derive wrapping key using HKDF + // Derive the session key using HKDF salt := tdfSalt() - wrapKey, err := ocrypto.CalculateHKDF(salt, ecdhKey) + sessionKey, err := ocrypto.CalculateHKDF(salt, ecdhKey) if err != nil { return "", "", "", fmt.Errorf("failed to derive wrap key: %w", err) } - // Ensure we have the right length for wrapping, trim if needed, or error if too short - if len(wrapKey) > len(symKey) { - wrapKey = wrapKey[:len(symKey)] - } else if len(wrapKey) < len(symKey) { - return "", "", "", fmt.Errorf("wrap key too short: got %d, expected at least %d", - len(wrapKey), len(symKey)) + // AES-GCM-encrypt the DEK under the session key. This is the envelope + // the KAS unwrap path expects; a raw XOR of the HKDF output is not + // decryptable by it. + gcm, err := ocrypto.NewAESGcm(sessionKey) + if err != nil { + return "", "", "", fmt.Errorf("failed to create AES-GCM: %w", err) } - wrapped := make([]byte, len(symKey)) - for i := range symKey { - wrapped[i] = symKey[i] ^ wrapKey[i] + wrapped, err := gcm.Encrypt(symKey) + if err != nil { + return "", "", "", fmt.Errorf("failed to wrap key: %w", err) } - return string(ocrypto.Base64Encode(wrapped)), "eccWrapped", ephemeralPubKey, nil + return string(ocrypto.Base64Encode(wrapped)), kECWrapped, ephemeralPubKey, nil } // wrapKeyWithRSA encrypts a key using RSA public key with OAEP padding @@ -258,9 +258,9 @@ func wrapKeyWithKEM(ktype ocrypto.KeyType, kasPublicKeyPEM string, symKey []byte if err != nil { return "", "", "", fmt.Errorf("kem wrap failed: %w", err) } - scheme := "hybrid-wrapped" + scheme := kHybridWrapped if ocrypto.IsMLKEMKeyType(ktype) { - scheme = "mlkem-wrapped" + scheme = kMLKEMWrapped } return string(ocrypto.Base64Encode(wrappedDER)), scheme, "", nil } diff --git a/sdk/experimental/tdf/key_access_test.go b/sdk/experimental/tdf/key_access_test.go index 6deac12f27..4c9e2e3c84 100644 --- a/sdk/experimental/tdf/key_access_test.go +++ b/sdk/experimental/tdf/key_access_test.go @@ -3,8 +3,11 @@ package tdf import ( + "crypto/ecdsa" "crypto/rand" + "crypto/x509" "encoding/json" + "encoding/pem" "strings" "testing" @@ -106,7 +109,7 @@ func TestBuildKeyAccessObjects(t *testing.T) { require.Len(t, keyAccessList, 1, "Should create exactly one key access object") keyAccess := keyAccessList[0] - assert.Equal(t, "eccWrapped", keyAccess.KeyType, "EC keys should use 'eccWrapped' key type") + assert.Equal(t, kECWrapped, keyAccess.KeyType, "EC keys should use the 'ec-wrapped' key type the KAS dispatches on") assert.Equal(t, testKAS1URL, keyAccess.KasURL, "Should preserve KAS URL") assert.NotEmpty(t, keyAccess.EphemeralPublicKey, "EC keys should have ephemeral public key") assert.NotEmpty(t, keyAccess.WrappedKey, "Should contain wrapped key data") @@ -477,7 +480,7 @@ func TestWrapKeyWithPublicKey(t *testing.T) { require.NoError(t, err, "Should wrap key with EC public key") assert.NotEmpty(t, wrappedKey, "Should return wrapped key") - assert.Equal(t, "eccWrapped", keyType, "EC keys should use 'eccWrapped' type") + assert.Equal(t, kECWrapped, keyType, "EC keys should use the 'ec-wrapped' type the KAS dispatches on") assert.NotEmpty(t, ephemeralPubKey, "EC should generate ephemeral public key") // Verify ephemeral key is valid PEM @@ -485,6 +488,37 @@ func TestWrapKeyWithPublicKey(t *testing.T) { "Ephemeral key should be in PEM format") assert.True(t, strings.HasSuffix(ephemeralPubKey, "-----END PUBLIC KEY-----\n"), "Ephemeral key should end with PEM footer") + + // Unwrap exactly the way service/kas/access/rewrap.go does for + // "ec-wrapped". Asserting only on keyType would not have caught + // the envelope being a raw XOR instead of AES-GCM. + kasPrivPEM, err := ecKeyPair.PrivateKeyInPemFormat() + require.NoError(t, err) + + keySize, err := ocrypto.GetECKeySize([]byte(ephemeralPubKey)) + require.NoError(t, err) + mode, err := ocrypto.ECSizeToMode(keySize) + require.NoError(t, err) + + block, _ := pem.Decode([]byte(ephemeralPubKey)) + require.NotNil(t, block) + pub, err := x509.ParsePKIXPublicKey(block.Bytes) + require.NoError(t, err) + ecPub, ok := pub.(*ecdsa.PublicKey) + require.True(t, ok) + compressed, err := ocrypto.CompressedECPublicKey(mode, *ecPub) + require.NoError(t, err) + + priv, err := ocrypto.ECPrivateKeyFromPem([]byte(kasPrivPEM)) + require.NoError(t, err) + dec, err := ocrypto.NewSaltedECDecryptor(priv, tdfSalt(), nil) + require.NoError(t, err) + + wrapped, err := ocrypto.Base64Decode([]byte(wrappedKey)) + require.NoError(t, err) + unwrapped, err := dec.DecryptWithEphemeralKey(wrapped, compressed) + require.NoError(t, err, "KAS must be able to unwrap the EC-wrapped DEK") + assert.Equal(t, symKey, unwrapped, "unwrapped DEK must round-trip") }) t.Run("wraps key with X-Wing public key", func(t *testing.T) { diff --git a/sdk/experimental/tdf/manifest.go b/sdk/experimental/tdf/manifest.go index 10f5eceb0f..d3cf05d30d 100644 --- a/sdk/experimental/tdf/manifest.go +++ b/sdk/experimental/tdf/manifest.go @@ -7,69 +7,91 @@ import ( "errors" "github.com/opentdf/platform/lib/ocrypto" + "github.com/opentdf/platform/sdk" ) const ( kGMACPayloadLength = 16 kSplitKeyType = "split" kPolicyBindingAlg = "HS256" -) -type RootSignature struct { - Algorithm string `json:"alg"` - Signature string `json:"sig"` -} + // Key wrap scheme names as they appear in a KeyAccess object's + // "type" field. These must match what the KAS rewrap handler + // dispatches on; see service/kas/access/rewrap.go. + kWrapped = "wrapped" + kECWrapped = "ec-wrapped" + kHybridWrapped = "hybrid-wrapped" + kMLKEMWrapped = "mlkem-wrapped" -type IntegrityInformation struct { - RootSignature `json:"rootSignature"` - SegmentHashAlgorithm string `json:"segmentHashAlg"` - DefaultSegmentSize int64 `json:"segmentSizeDefault"` - DefaultEncryptedSegSize int64 `json:"encryptedSegmentSizeDefault"` - Segments []Segment `json:"segments"` -} - -type KeyAccess struct { - KeyType string `json:"type"` - KasURL string `json:"url"` - Protocol string `json:"protocol"` - WrappedKey string `json:"wrappedKey"` - PolicyBinding interface{} `json:"policyBinding"` - EncryptedMetadata string `json:"encryptedMetadata,omitempty"` - KID string `json:"kid,omitempty"` - SplitID string `json:"sid,omitempty"` - SchemaVersion string `json:"schemaVersion,omitempty"` - EphemeralPublicKey string `json:"ephemeralPublicKey,omitempty"` -} - -type Method struct { - Algorithm string `json:"algorithm"` - IV string `json:"iv"` - IsStreamable bool `json:"isStreamable"` -} - -type Payload struct { - Type string `json:"type"` - URL string `json:"url"` - Protocol string `json:"protocol"` - MimeType string `json:"mimeType"` - IsEncrypted bool `json:"isEncrypted"` - // IntegrityInformation IntegrityInformation `json:"integrityInformation"` -} + // kKasProtocol is the only protocol value the KAS understands. + kKasProtocol = "kas" +) -type EncryptionInformation struct { - KeyAccessType string `json:"type"` - Policy string `json:"policy"` - KeyAccessObjs []KeyAccess `json:"keyAccess"` - Method Method `json:"method"` - IntegrityInformation `json:"integrityInformation"` -} +// The manifest types below are aliases onto their +// [github.com/opentdf/platform/sdk] counterparts, which own the definitions. +// They are kept here so that existing importers of this experimental package +// continue to compile unchanged, and so a manifest produced here can be +// handed to the stable SDK without conversion. Prefer the sdk-scoped names in +// new code. +type ( + // RootSignature is the signature over the concatenated segment hashes. + // + // See [sdk.RootSignature]. + RootSignature = sdk.RootSignature + + // IntegrityInformation describes segment layout and the hashes that + // protect the payload. + // + // See [sdk.IntegrityInformation]. + IntegrityInformation = sdk.IntegrityInformation + + // KeyAccess is one wrapped key share addressed to a single KAS. + // + // See [sdk.KeyAccess]. + KeyAccess = sdk.KeyAccess + + // Method describes the payload encryption algorithm and IV. + // + // See [sdk.Method]. + Method = sdk.Method + + // Payload describes the encrypted payload entry in the archive. + // + // See [sdk.Payload]. + Payload = sdk.Payload + + // EncryptionInformation carries the policy, key access objects, and + // integrity information for a TDF. + // + // See [sdk.EncryptionInformation]. + EncryptionInformation = sdk.EncryptionInformation + + // Manifest is the TDF manifest written to manifest.json. + // + // See [sdk.Manifest]. + Manifest = sdk.Manifest + + // Segment is one encrypted chunk of the payload plus its integrity hash. + // + // See [sdk.Segment]. + Segment = sdk.Segment + + // PolicyBinding is the HMAC binding a key share to the policy. + // + // See [sdk.PolicyBinding]. + PolicyBinding = sdk.PolicyBinding + + // EncryptedMetadata is the AES-GCM envelope for a key access object's + // opaque metadata. + // + // See [sdk.EncryptedMetadata]. + EncryptedMetadata = sdk.EncryptedMetadata +) -type Manifest struct { - EncryptionInformation `json:"encryptionInformation"` - Payload `json:"payload"` - Assertions []Assertion `json:"assertions,omitempty"` - TDFVersion string `json:"schemaVersion,omitempty"` -} +// Policy, PolicyBody, and PolicyAttribute are deliberately not aliased onto +// [sdk.PolicyObject]: the sdk type declares Body as an anonymous struct over +// an unexported element type, so it has no nameable equivalent for these +// three. Exporting those in sdk first would make the alias possible. type PolicyAttribute struct { Attribute string `json:"attribute"` @@ -89,20 +111,6 @@ type PolicyBody struct { Dissem []string `json:"dissem"` } -type Segment struct { - Hash string `json:"hash"` - Size int64 `json:"segmentSize"` - EncryptedSize int64 `json:"encryptedSegmentSize"` -} -type PolicyBinding struct { - Alg string `json:"alg"` - Hash string `json:"hash"` -} -type EncryptedMetadata struct { - Cipher string `json:"ciphertext"` - Iv string `json:"iv"` -} - func calculateSignature(data []byte, secret []byte, alg IntegrityAlgorithm, isLegacyTDF bool) (string, error) { if alg == HS256 { hmac := ocrypto.CalculateSHA256Hmac(secret, data) diff --git a/sdk/experimental/tdf/options.go b/sdk/experimental/tdf/options.go index e2c7e70d31..9032baa28d 100644 --- a/sdk/experimental/tdf/options.go +++ b/sdk/experimental/tdf/options.go @@ -11,6 +11,12 @@ import "github.com/opentdf/platform/protocol/go/policy" // - GMAC: Galois Message Authentication Code, faster but requires AES-GCM support // // The algorithm choice affects both segment-level and root-level integrity verification. +// +// Unlike the manifest and assertion types in this package, this is not an +// alias onto [sdk.IntegrityAlgorithm]: that one is itself an alias for int, so +// no methods can be attached to it, and aliasing would silently drop String() +// from this package's public API. The underlying values match, so the two +// convert freely. type IntegrityAlgorithm int // String returns the string representation of the integrity algorithm. diff --git a/sdk/key_splitter.go b/sdk/key_splitter.go index c730e9c158..24057ec7e1 100644 --- a/sdk/key_splitter.go +++ b/sdk/key_splitter.go @@ -60,6 +60,18 @@ type KASPublicKey struct { URL string } +// toKASInfo adapts the splitter's wrapping-key descriptor to the +// KASInfo shape consumed by createKeyAccess. Default is not carried +// over; it plays no part in building a key access object. +func (k KASPublicKey) toKASInfo() KASInfo { + return KASInfo{ + URL: k.URL, + PublicKey: k.PEM, + KID: k.KID, + Algorithm: k.Algorithm, + } +} + // ErrSplitterRequiresDefaultKAS is returned by the default key // splitter when no default KAS was supplied. The default splitter is // single-KAS only; multi-attribute splits require injecting a full @@ -104,9 +116,8 @@ func (s *singleKASSplitter) Split(_ context.Context, _ []*policy.Value, dek []by // algorithmPolicyToString maps a policy.Algorithm enum to the // ocrypto.KeyType string form used when picking a wrap scheme. -// Unknown enums (including PQ / KEM variants not yet wired through -// chunkedWrapKeyWithPublicKey) return the empty string; the caller -// treats that as "unsupported algorithm." +// Unknown enums return the empty string; createKeyAccess then falls +// through to its RSA branch, which fails on a non-RSA PEM. func algorithmPolicyToString(a policy.Algorithm) string { if kt, err := PolicyAlgorithmToKeyType(a); err == nil { return string(kt) diff --git a/sdk/tdf.go b/sdk/tdf.go index ce42099603..e2cc33a244 100644 --- a/sdk/tdf.go +++ b/sdk/tdf.go @@ -302,21 +302,12 @@ func (s SDK) CreateTDFContext(ctx context.Context, writer io.Writer, reader io.R sig := string(ocrypto.Base64Encode([]byte(rootSignature))) tdfObject.manifest.Signature = sig - integrityAlgStr := gmacIntegrityAlgorithm - if tdfConfig.integrityAlgorithm == HS256 { - integrityAlgStr = hmacIntegrityAlgorithm - } - tdfObject.manifest.Algorithm = integrityAlgStr + tdfObject.manifest.Algorithm = integrityAlgorithmString(tdfConfig.integrityAlgorithm) tdfObject.manifest.DefaultSegmentSize = segmentSize tdfObject.manifest.DefaultEncryptedSegSize = encryptedSegmentSize - segIntegrityAlgStr := gmacIntegrityAlgorithm - if tdfConfig.segmentIntegrityAlgorithm == HS256 { - segIntegrityAlgStr = hmacIntegrityAlgorithm - } - - tdfObject.manifest.SegmentHashAlgorithm = segIntegrityAlgStr + tdfObject.manifest.SegmentHashAlgorithm = integrityAlgorithmString(tdfConfig.segmentIntegrityAlgorithm) tdfObject.manifest.Method.IsStreamable = true // add payload info @@ -588,12 +579,7 @@ func (s SDK) prepareManifest(ctx context.Context, t *TDFObject, tdfConfig TDFCon symKeys = append(symKeys, symKey) // policy binding - policyBindingHash := hex.EncodeToString(ocrypto.CalculateSHA256Hmac(symKey, base64PolicyObject)) - pbstring := string(ocrypto.Base64Encode([]byte(policyBindingHash))) - policyBinding := PolicyBinding{ - Alg: "HS256", - Hash: pbstring, - } + policyBinding := createPolicyBinding(symKey, base64PolicyObject) // encrypted metadata // add meta data @@ -639,6 +625,27 @@ func (s SDK) prepareManifest(ctx context.Context, t *TDFObject, tdfConfig TDFCon return nil } +// integrityAlgorithmString maps an IntegrityAlgorithm to its manifest +// string form. +func integrityAlgorithmString(a IntegrityAlgorithm) string { + switch a { + case GMAC: + return gmacIntegrityAlgorithm + default: + return hmacIntegrityAlgorithm + } +} + +// createPolicyBinding produces an HMAC-SHA256 binding value keyed on the +// symmetric key, over the base64-encoded policy object. +func createPolicyBinding(symKey []byte, base64PolicyObject []byte) PolicyBinding { + policyBindingHash := hex.EncodeToString(ocrypto.CalculateSHA256Hmac(symKey, base64PolicyObject)) + return PolicyBinding{ + Alg: hmacIntegrityAlgorithm, + Hash: string(ocrypto.Base64Encode([]byte(policyBindingHash))), + } +} + func encryptMetadata(symKey []byte, metaData string) (string, error) { gcm, err := ocrypto.NewAESGcm(symKey) if err != nil {