Skip to content

Commit e819e09

Browse files
pflynn-virtruclaude
andcommitted
feat(sdk): implement single-segment TDF3 encrypt in WASM module
Replace the tdf_encrypt stub with a complete single-segment TDF3 encrypt path running inside the WASM sandbox. All crypto is delegated to the host via hostcrypto; manifest construction, policy binding, HS256 integrity, and ZIP assembly run inside WASM using tinyjson types and zipstream. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2f39673 commit e819e09

3 files changed

Lines changed: 248 additions & 22 deletions

File tree

go.work

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ use (
88
./lib/ocrypto
99
./protocol/go
1010
./sdk
11+
./sdk/experimental/tdf/wasm/tinyjson
12+
./sdk/experimental/tdf/wasm/zipstream
1113
./service
1214
./tests-bdd
1315
)
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
//go:build wasip1
2+
3+
package main
4+
5+
import (
6+
"bytes"
7+
"context"
8+
"encoding/base64"
9+
"encoding/hex"
10+
"hash/crc32"
11+
12+
"github.com/opentdf/platform/sdk/experimental/tdf/wasm/hostcrypto"
13+
"github.com/opentdf/platform/sdk/experimental/tdf/wasm/tinyjson/types"
14+
zs "github.com/opentdf/platform/sdk/experimental/tdf/wasm/zipstream/zipstream"
15+
)
16+
17+
// encrypt performs a single-segment TDF3 encryption. All crypto is delegated
18+
// to the host via hostcrypto; manifest construction, integrity computation,
19+
// and ZIP assembly run inside the WASM sandbox.
20+
func encrypt(kasPubPEM, kasURL string, attrs []string, plaintext []byte) ([]byte, error) {
21+
// 1. Generate 32-byte AES-256 DEK
22+
dek, err := hostcrypto.RandomBytes(32)
23+
if err != nil {
24+
return nil, err
25+
}
26+
27+
// 2. RSA-OAEP wrap DEK with KAS public key
28+
wrappedKey, err := hostcrypto.RsaOaepSha1Encrypt(kasPubPEM, dek)
29+
if err != nil {
30+
return nil, err
31+
}
32+
33+
// 3. Generate pseudo-UUID for policy
34+
uuid, err := generatePseudoUUID()
35+
if err != nil {
36+
return nil, err
37+
}
38+
39+
// 4. Build policy JSON using tinyjson types
40+
policyJSON, err := buildPolicyJSON(uuid, attrs)
41+
if err != nil {
42+
return nil, err
43+
}
44+
45+
// 5. Base64-encode policy
46+
base64Policy := base64.StdEncoding.EncodeToString(policyJSON)
47+
48+
// 6. Policy binding: HMAC-SHA256(dek, base64Policy) → hex → base64
49+
// Double-encoding required for Go SDK decrypt compatibility
50+
bindingHMAC, err := hostcrypto.HmacSHA256(dek, []byte(base64Policy))
51+
if err != nil {
52+
return nil, err
53+
}
54+
bindingHash := base64.StdEncoding.EncodeToString([]byte(hex.EncodeToString(bindingHMAC)))
55+
56+
// 7. Encrypt plaintext with AES-256-GCM
57+
// Returns [nonce(12) || ciphertext || tag(16)]
58+
fullCT, err := hostcrypto.AesGcmEncrypt(dek, plaintext)
59+
if err != nil {
60+
return nil, err
61+
}
62+
63+
// 8. cipher = fullCT[12:] (ciphertext+tag, without nonce)
64+
cipher := fullCT[12:]
65+
66+
// 9. Segment integrity: HMAC-SHA256(dek, cipher) → base64
67+
segmentSig, err := hostcrypto.HmacSHA256(dek, cipher)
68+
if err != nil {
69+
return nil, err
70+
}
71+
segmentHash := base64.StdEncoding.EncodeToString(segmentSig)
72+
73+
// 10. Root signature: HMAC-SHA256(dek, raw_segment_hmac) → base64
74+
// For single segment, input is the raw segment HMAC bytes directly
75+
rootSig, err := hostcrypto.HmacSHA256(dek, segmentSig)
76+
if err != nil {
77+
return nil, err
78+
}
79+
rootSigB64 := base64.StdEncoding.EncodeToString(rootSig)
80+
81+
// 11. Build manifest
82+
manifest := types.Manifest{
83+
TDFVersion: "4.3.0",
84+
EncryptionInformation: types.EncryptionInformation{
85+
KeyAccessType: "split",
86+
Policy: base64Policy,
87+
KeyAccessObjs: []types.KeyAccess{{
88+
KeyType: "wrapped",
89+
KasURL: kasURL,
90+
Protocol: "kas",
91+
WrappedKey: base64.StdEncoding.EncodeToString(wrappedKey),
92+
PolicyBinding: types.PolicyBinding{
93+
Alg: "HS256",
94+
Hash: bindingHash,
95+
},
96+
}},
97+
Method: types.Method{
98+
Algorithm: "AES-256-GCM",
99+
IsStreamable: true,
100+
},
101+
IntegrityInformation: types.IntegrityInformation{
102+
RootSignature: types.RootSignature{
103+
Algorithm: "HS256",
104+
Signature: rootSigB64,
105+
},
106+
SegmentHashAlgorithm: "HS256",
107+
DefaultSegmentSize: int64(len(plaintext)),
108+
DefaultEncryptedSegSize: int64(len(fullCT)),
109+
Segments: []types.Segment{{
110+
Hash: segmentHash,
111+
Size: int64(len(plaintext)),
112+
EncryptedSize: int64(len(fullCT)),
113+
}},
114+
},
115+
},
116+
Payload: types.Payload{
117+
Type: "reference",
118+
URL: "0.payload",
119+
Protocol: "zip",
120+
MimeType: "application/octet-stream",
121+
IsEncrypted: true,
122+
},
123+
}
124+
125+
manifestJSON, err := manifest.MarshalJSON()
126+
if err != nil {
127+
return nil, err
128+
}
129+
130+
// 12. Assemble ZIP
131+
crc32Sum := crc32.ChecksumIEEE(fullCT)
132+
sw := zs.NewSegmentTDFWriter(1)
133+
ctx := context.Background()
134+
135+
header, err := sw.WriteSegment(ctx, 0, uint64(len(fullCT)), crc32Sum)
136+
if err != nil {
137+
return nil, err
138+
}
139+
140+
tail, err := sw.Finalize(ctx, manifestJSON)
141+
if err != nil {
142+
return nil, err
143+
}
144+
145+
// result = header + fullCT + tail
146+
var result bytes.Buffer
147+
result.Grow(len(header) + len(fullCT) + len(tail))
148+
result.Write(header)
149+
result.Write(fullCT)
150+
result.Write(tail)
151+
152+
return result.Bytes(), nil
153+
}
154+
155+
// generatePseudoUUID generates a UUID v4-like string from 16 random bytes.
156+
// Format: xxxxxxxx-xxxx-4xxx-Nxxx-xxxxxxxxxxxx (version 4, variant 1).
157+
func generatePseudoUUID() (string, error) {
158+
b, err := hostcrypto.RandomBytes(16)
159+
if err != nil {
160+
return "", err
161+
}
162+
// Set version 4 (bits 12-15 of time_hi_and_version)
163+
b[6] = (b[6] & 0x0f) | 0x40
164+
// Set variant 1 (bits 6-7 of clock_seq_hi_and_reserved)
165+
b[8] = (b[8] & 0x3f) | 0x80
166+
167+
return hex.EncodeToString(b[0:4]) + "-" +
168+
hex.EncodeToString(b[4:6]) + "-" +
169+
hex.EncodeToString(b[6:8]) + "-" +
170+
hex.EncodeToString(b[8:10]) + "-" +
171+
hex.EncodeToString(b[10:16]), nil
172+
}
173+
174+
// buildPolicyJSON constructs a TDF policy and marshals it to JSON via tinyjson.
175+
func buildPolicyJSON(uuid string, attrs []string) ([]byte, error) {
176+
dataAttrs := make([]types.PolicyAttribute, len(attrs))
177+
for i, attr := range attrs {
178+
dataAttrs[i] = types.PolicyAttribute{
179+
Attribute: attr,
180+
}
181+
}
182+
183+
policy := types.Policy{
184+
UUID: uuid,
185+
Body: types.PolicyBody{
186+
DataAttributes: dataAttrs,
187+
Dissem: []string{},
188+
},
189+
}
190+
191+
return policy.MarshalJSON()
192+
}

sdk/experimental/tdf/wasm/main.go

Lines changed: 54 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,27 +4,20 @@
44
//
55
// This is the entry point for the hybrid WASM TDF engine. All crypto
66
// operations are delegated to the host via the hostcrypto package;
7-
// the TDF logic (manifest construction, ZIP packaging, key splitting,
8-
// integrity) runs inside the WASM sandbox.
7+
// the TDF logic (manifest construction, ZIP packaging, integrity)
8+
// runs inside the WASM sandbox.
99
//
10-
// EXPECTED TO FAIL under TinyGo until the spike work is complete.
1110
// See: docs/adr/spike-wasm-core-tinygo-hybrid.md
12-
//
13-
// Blockers:
14-
// - lib/ocrypto → replaced by hostcrypto (go:wasmimport host functions)
15-
// - github.com/google/uuid → generate on host or use TinyGo-compatible lib
16-
// - protocol/go/policy → decouple from writer or provide lightweight types
17-
// - log/slog → replace with minimal logger or remove
1811
package main
1912

2013
import (
21-
"context"
14+
"strings"
2215
"unsafe"
23-
24-
"github.com/opentdf/platform/sdk/experimental/tdf"
25-
"github.com/opentdf/platform/sdk/experimental/tdf/wasm/hostcrypto"
2616
)
2717

18+
// lastError holds the most recent error message for the host to retrieve.
19+
var lastError string
20+
2821
// ── Exported WASM functions ─────────────────────────────────────────
2922
// Called by the host to perform TDF operations.
3023

@@ -39,6 +32,21 @@ func wasmFree(_ uint32) {
3932
// No-op with leaking GC; tracked for future improvement
4033
}
4134

35+
//go:wasmexport get_error
36+
func getError(outPtr, outCapacity uint32) uint32 {
37+
if lastError == "" {
38+
return 0
39+
}
40+
msg := lastError
41+
if uint32(len(msg)) > outCapacity {
42+
msg = msg[:outCapacity]
43+
}
44+
dst := unsafe.Slice((*byte)(unsafe.Pointer(uintptr(outPtr))), len(msg))
45+
copy(dst, msg)
46+
lastError = ""
47+
return uint32(len(msg))
48+
}
49+
4250
//go:wasmexport tdf_encrypt
4351
func tdfEncrypt(
4452
kasPubPtr, kasPubLen uint32,
@@ -47,23 +55,47 @@ func tdfEncrypt(
4755
ptPtr, ptLen uint32,
4856
outPtr, outCapacity uint32,
4957
) uint32 {
50-
// Stub — spike will implement the full encrypt path here
51-
ctx := context.Background()
58+
kasPubPEM := ptrToString(kasPubPtr, kasPubLen)
59+
kasURL := ptrToString(kasURLPtr, kasURLLen)
60+
61+
var attrs []string
62+
if attrLen > 0 {
63+
attrStr := ptrToString(attrPtr, attrLen)
64+
attrs = strings.Split(attrStr, "\n")
65+
}
5266

53-
// Validate that the tdf package is reachable
54-
w, err := tdf.NewWriter(ctx)
67+
plaintext := ptrToBytes(ptPtr, ptLen)
68+
69+
result, err := encrypt(kasPubPEM, kasURL, attrs, plaintext)
5570
if err != nil {
71+
lastError = err.Error()
5672
return 0
5773
}
58-
_ = w
5974

60-
// Validate that host crypto wrappers are linked
61-
_, err = hostcrypto.RandomBytes(32)
62-
if err != nil {
75+
if uint32(len(result)) > outCapacity {
76+
lastError = "output buffer too small"
6377
return 0
6478
}
6579

66-
return 0
80+
dst := unsafe.Slice((*byte)(unsafe.Pointer(uintptr(outPtr))), len(result))
81+
copy(dst, result)
82+
return uint32(len(result))
83+
}
84+
85+
// ── WASM memory helpers ─────────────────────────────────────────────
86+
87+
func ptrToString(ptr, length uint32) string {
88+
if length == 0 {
89+
return ""
90+
}
91+
return unsafe.String((*byte)(unsafe.Pointer(uintptr(ptr))), length)
92+
}
93+
94+
func ptrToBytes(ptr, length uint32) []byte {
95+
if length == 0 {
96+
return nil
97+
}
98+
return unsafe.Slice((*byte)(unsafe.Pointer(uintptr(ptr))), length)
6799
}
68100

69101
func main() {

0 commit comments

Comments
 (0)