Skip to content

Commit d795677

Browse files
committed
refactor: remove dot-separated token format, keep only compact + legacy
The dot-separated format (sk-ac-{auth}.{encrypted_mk}) was never deployed. Remove it to simplify the codebase: - Remove WrapMasterKey/UnwrapMasterKey (AES-GCM token wrapping) - Remove EncryptedMasterKey field from ParsedToken - Remove dot-parsing branch in ParseProjectToken - Remove dot-format test cases - Remove generateRandomSecret (unused after compact format) - Update Python E2E tests to use compact format with AES Key Wrap S3 envelope encryption (WrapDEK/UnwrapDEK via AES-GCM) is unchanged.
1 parent 9792ea7 commit d795677

7 files changed

Lines changed: 50 additions & 212 deletions

File tree

src/server/api/go/internal/infra/crypto/envelope_test.go

Lines changed: 0 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -288,43 +288,6 @@ func TestDeriveUserKEK_Deterministic(t *testing.T) {
288288
}
289289
}
290290

291-
func TestWrapUnwrapMasterKey(t *testing.T) {
292-
wk, _ := DeriveUserKEK("auth-secret", "pepper")
293-
mk, err := GenerateMasterKey()
294-
if err != nil {
295-
t.Fatal(err)
296-
}
297-
298-
encB64, err := WrapMasterKey(wk, mk)
299-
if err != nil {
300-
t.Fatal(err)
301-
}
302-
if encB64 == "" {
303-
t.Fatal("encrypted master key should not be empty")
304-
}
305-
306-
unwrapped, err := UnwrapMasterKey(wk, encB64)
307-
if err != nil {
308-
t.Fatal(err)
309-
}
310-
if !bytes.Equal(mk, unwrapped) {
311-
t.Fatal("unwrapped master key should match original")
312-
}
313-
}
314-
315-
func TestUnwrapMasterKey_WrongWrappingKey(t *testing.T) {
316-
wk1, _ := DeriveUserKEK("auth1", "pepper")
317-
wk2, _ := DeriveUserKEK("auth2", "pepper")
318-
mk, _ := GenerateMasterKey()
319-
320-
encB64, _ := WrapMasterKey(wk1, mk)
321-
322-
_, err := UnwrapMasterKey(wk2, encB64)
323-
if err == nil {
324-
t.Fatal("should fail with wrong wrapping key")
325-
}
326-
}
327-
328291
func TestMasterKeyAsKEK(t *testing.T) {
329292
// master_key is used directly as KEK for wrapping S3 DEKs
330293
mk, _ := GenerateMasterKey()
@@ -344,41 +307,6 @@ func TestMasterKeyAsKEK(t *testing.T) {
344307
}
345308
}
346309

347-
func TestMasterKeyRewrapPreservesDecryption(t *testing.T) {
348-
// Simulate: old auth_secret → new auth_secret, same master_key
349-
// S3 data should still decrypt with the same master_key
350-
mk, _ := GenerateMasterKey()
351-
352-
// Encrypt data with master_key as KEK
353-
plaintext := []byte("data that should survive auth rotation")
354-
ciphertext, meta, _ := EncryptData(mk, plaintext)
355-
356-
// Re-wrap master_key with new auth_secret (simulating rotation)
357-
oldWK, _ := DeriveUserKEK("old-auth", "pepper")
358-
newWK, _ := DeriveUserKEK("new-auth", "pepper")
359-
360-
encB64, _ := WrapMasterKey(oldWK, mk)
361-
unwrapped, _ := UnwrapMasterKey(oldWK, encB64)
362-
363-
// Re-wrap with new wrapping key
364-
newEncB64, _ := WrapMasterKey(newWK, unwrapped)
365-
rewrappedMK, _ := UnwrapMasterKey(newWK, newEncB64)
366-
367-
// master_key should be the same
368-
if !bytes.Equal(mk, rewrappedMK) {
369-
t.Fatal("master key should be preserved across auth rotation")
370-
}
371-
372-
// S3 data should still decrypt
373-
decrypted, err := DecryptData(rewrappedMK, ciphertext, meta)
374-
if err != nil {
375-
t.Fatal("data should decrypt with same master key after auth rotation:", err)
376-
}
377-
if !bytes.Equal(plaintext, decrypted) {
378-
t.Fatal("decrypted should match plaintext")
379-
}
380-
}
381-
382310
func TestMetadataToMap_NoAdminDEK(t *testing.T) {
383311
meta := &EncryptedMeta{
384312
Algo: "AES-256-GCM",

src/server/api/go/internal/infra/crypto/service.go

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -25,30 +25,6 @@ func DeriveUserKEK(authSecret, pepper string) ([]byte, error) {
2525
return DeriveKEK(secret, salt, info)
2626
}
2727

28-
// WrapMasterKey encrypts a 32-byte master key with a wrapping key derived from auth_secret.
29-
// Returns base64url-encoded ciphertext (nonce + encrypted master key).
30-
func WrapMasterKey(wrappingKey, masterKey []byte) (string, error) {
31-
wrapped, err := WrapDEK(wrappingKey, masterKey)
32-
if err != nil {
33-
return "", fmt.Errorf("crypto: wrap master key: %w", err)
34-
}
35-
return base64.RawURLEncoding.EncodeToString(wrapped), nil
36-
}
37-
38-
// UnwrapMasterKey decrypts a base64url-encoded encrypted master key using the wrapping key.
39-
// Returns the raw 32-byte master key.
40-
func UnwrapMasterKey(wrappingKey []byte, encryptedMasterKeyB64 string) ([]byte, error) {
41-
wrapped, err := base64.RawURLEncoding.DecodeString(encryptedMasterKeyB64)
42-
if err != nil {
43-
return nil, fmt.Errorf("crypto: decode encrypted master key: %w", err)
44-
}
45-
mk, err := UnwrapDEK(wrappingKey, wrapped)
46-
if err != nil {
47-
return nil, fmt.Errorf("crypto: unwrap master key: %w", err)
48-
}
49-
return mk, nil
50-
}
51-
5228
// GenerateMasterKey generates a random 32-byte master key for use as a KEK.
5329
func GenerateMasterKey() ([]byte, error) {
5430
return GenerateDEK() // same size: 32 bytes

src/server/api/go/internal/middleware/auth.go

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -148,29 +148,15 @@ func ProjectAuth(cfg *config.Config, db *gorm.DB, rdb *redis.Client) gin.Handler
148148
c.Set("project", project)
149149
SetWideEventField(c, "project_id", project.ID.String())
150150

151-
// Derive KEK: unwrap master_key from token if present.
152-
// Compact tokens carry AES-KW wrapped master key; v1 tokens carry AES-GCM wrapped.
153-
// Legacy keys without encrypted_master_key have no encryption support.
151+
// Derive KEK from compact token if present.
152+
// Legacy keys without CompactRaw have no encryption support.
154153
if parsed.CompactRaw != "" {
155-
// Compact format: UnpackCompactToken handles derivation + AES-KW unwrap
156154
_, userKEK, kerr := encryptionpkg.UnpackCompactToken(parsed.CompactRaw, cfg.Root.SecretPepper)
157155
if kerr != nil {
158156
c.AbortWithStatusJSON(http.StatusUnauthorized, serializer.AuthErr("invalid API key: failed to unwrap compact token"))
159157
return
160158
}
161159
c.Set("user_kek", userKEK)
162-
} else if parsed.EncryptedMasterKey != "" {
163-
wrappingKey, wkErr := encryptionpkg.DeriveUserKEK(parsed.AuthSecret, cfg.Root.SecretPepper)
164-
if wkErr != nil {
165-
c.AbortWithStatusJSON(http.StatusInternalServerError, serializer.DBErr("derive wrapping key", wkErr))
166-
return
167-
}
168-
userKEK, kerr := encryptionpkg.UnwrapMasterKey(wrappingKey, parsed.EncryptedMasterKey)
169-
if kerr != nil {
170-
c.AbortWithStatusJSON(http.StatusUnauthorized, serializer.AuthErr("invalid API key: failed to unwrap master key"))
171-
return
172-
}
173-
c.Set("user_kek", userKEK)
174160
}
175161

176162
c.Next()

src/server/api/go/internal/modules/service/project.go

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package service
33
import (
44
"context"
55
"crypto/rand"
6-
"encoding/base64"
76
"encoding/hex"
87
"errors"
98
"net/http"
@@ -58,15 +57,6 @@ type UpdateSecretKeyOutput struct {
5857
SecretKey string `json:"secret_key"`
5958
}
6059

61-
// generateRandomSecret generates a random secret key with the specified byte length
62-
func generateRandomSecret(byteLength int) (string, error) {
63-
b := make([]byte, byteLength)
64-
if _, err := rand.Read(b); err != nil {
65-
return "", err
66-
}
67-
return base64.RawURLEncoding.EncodeToString(b), nil
68-
}
69-
7060
func (s *projectService) Create(ctx context.Context, configs map[string]interface{}) (*CreateProjectOutput, error) {
7161
pepper := s.cfg.Root.SecretPepper
7262
if pepper == "" {

src/server/api/go/internal/pkg/utils/tokens/tokens.go

Lines changed: 7 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -25,20 +25,17 @@ func ParseToken(raw, prefix string) (secret string, ok bool) {
2525

2626
// ParsedToken holds the parsed components of a project API key.
2727
// Supported formats:
28-
// - Compact: sk-ac-{base64url(0x01 | auth_16B | aes_kw(mk))} — no dot, CompactRaw set
29-
// - V1: sk-ac-{auth_secret}.{encrypted_master_key} — dot-separated
30-
// - Legacy: sk-ac-{auth_secret} — no dot, no encryption
28+
// - Compact: sk-ac-{base64url(0x01 | auth_16B | aes_kw(mk))} — 76 chars, CompactRaw set
29+
// - Legacy: sk-ac-{auth_secret} — plain text, no encryption
3130
type ParsedToken struct {
32-
AuthSecret string // raw auth secret (used for HMAC lookup / Argon2 verification)
33-
EncryptedMasterKey string // base64url-encoded encrypted master key (empty for legacy/compact)
34-
CompactRaw string // non-empty if compact format detected (the full base64url body)
31+
AuthSecret string // auth secret string (used for HMAC lookup / Argon2 verification)
32+
CompactRaw string // non-empty if compact format detected (the full base64url body)
3533
}
3634

3735
// ParseProjectToken parses a raw Bearer token into its components.
3836
// Formats (checked in order):
39-
// 1. Dot-separated: sk-ac-{auth_secret}.{encrypted_master_key}
40-
// 2. Compact: sk-ac-{base64url(0x01 | auth_16B | aes_kw_40B)} — 76 chars, no dot
41-
// 3. Legacy: sk-ac-{auth_secret} — no dot, no encryption
37+
// 1. Compact: sk-ac-{base64url(0x01 | auth_16B | aes_kw_40B)} — 76 chars
38+
// 2. Legacy: sk-ac-{auth_secret} — no encryption
4239
//
4340
// Returns ok=false if the prefix doesn't match.
4441
func ParseProjectToken(raw, prefix string) (parsed ParsedToken, ok bool) {
@@ -50,19 +47,9 @@ func ParseProjectToken(raw, prefix string) (parsed ParsedToken, ok bool) {
5047
return ParsedToken{}, false
5148
}
5249

53-
// Dot-separated format: {auth_secret}.{encrypted_master_key}
54-
if idx := strings.IndexByte(body, '.'); idx > 0 && idx < len(body)-1 {
55-
return ParsedToken{
56-
AuthSecret: body[:idx],
57-
EncryptedMasterKey: body[idx+1:],
58-
}, true
59-
}
60-
6150
// Compact format: base64url(0x01 | auth_16B | aes_kw_40B) = 57 raw bytes = 76 base64url chars
62-
// Detected by: no dot + correct base64url length + version byte 0x01
63-
if !strings.Contains(body, ".") && len(body) == 76 {
51+
if len(body) == 76 {
6452
if decoded, err := base64.RawURLEncoding.DecodeString(body); err == nil && len(decoded) == 57 && decoded[0] == 0x01 {
65-
// Extract auth_secret as hex for HMAC/Argon2 compatibility
6653
authSecretHex := hex.EncodeToString(decoded[1:17])
6754
return ParsedToken{
6855
AuthSecret: authSecretHex,

src/server/api/go/internal/pkg/utils/tokens/tokens_test.go

Lines changed: 24 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -71,36 +71,24 @@ func TestParseProjectToken(t *testing.T) {
7171
prefix := "sk-ac-"
7272

7373
tests := []struct {
74-
name string
75-
raw string
76-
want ParsedToken
77-
wantOK bool
74+
name string
75+
raw string
76+
wantAuth string
77+
wantCompact bool
78+
wantOK bool
7879
}{
7980
{
80-
name: "new format with dot separator",
81-
raw: "sk-ac-authsecret123.encryptedmasterkey456",
82-
want: ParsedToken{
83-
AuthSecret: "authsecret123",
84-
EncryptedMasterKey: "encryptedmasterkey456",
85-
},
86-
wantOK: true,
81+
name: "legacy format",
82+
raw: "sk-ac-somelegacysecretvalue",
83+
wantAuth: "somelegacysecretvalue",
84+
wantOK: true,
8785
},
8886
{
89-
name: "legacy format without dot",
90-
raw: "sk-ac-somelegacysecretvalue",
91-
want: ParsedToken{
92-
AuthSecret: "somelegacysecretvalue",
93-
},
94-
wantOK: true,
95-
},
96-
{
97-
name: "new format with base64url chars including dashes",
98-
raw: "sk-ac-abc-def_ghi.xyz-uvw_123",
99-
want: ParsedToken{
100-
AuthSecret: "abc-def_ghi",
101-
EncryptedMasterKey: "xyz-uvw_123",
102-
},
103-
wantOK: true,
87+
name: "compact format (76 chars)",
88+
raw: "sk-ac-AaGyw9Tl9qe4ydDh8qO0xdZNkrobQvwHWFRsnp5a3QtfbaDSDJQeRHxXPr4bGpc0g130EqBSjRNF",
89+
wantAuth: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
90+
wantCompact: true,
91+
wantOK: true,
10492
},
10593
{
10694
name: "wrong prefix",
@@ -118,29 +106,10 @@ func TestParseProjectToken(t *testing.T) {
118106
wantOK: false,
119107
},
120108
{
121-
name: "dot at end (no encrypted master key) → treated as legacy",
122-
raw: "sk-ac-authsecret.",
123-
want: ParsedToken{
124-
AuthSecret: "authsecret.",
125-
},
126-
wantOK: true,
127-
},
128-
{
129-
name: "dot at start (no auth secret) → treated as legacy",
130-
raw: "sk-ac-.encryptedmasterkey",
131-
want: ParsedToken{
132-
AuthSecret: ".encryptedmasterkey",
133-
},
134-
wantOK: true,
135-
},
136-
{
137-
name: "multiple dots uses first dot as separator",
138-
raw: "sk-ac-auth.enc.extra",
139-
want: ParsedToken{
140-
AuthSecret: "auth",
141-
EncryptedMasterKey: "enc.extra",
142-
},
143-
wantOK: true,
109+
name: "short token treated as legacy",
110+
raw: "sk-ac-short",
111+
wantAuth: "short",
112+
wantOK: true,
144113
},
145114
}
146115

@@ -149,8 +118,12 @@ func TestParseProjectToken(t *testing.T) {
149118
parsed, ok := ParseProjectToken(tt.raw, prefix)
150119
assert.Equal(t, tt.wantOK, ok)
151120
if ok {
152-
assert.Equal(t, tt.want.AuthSecret, parsed.AuthSecret)
153-
assert.Equal(t, tt.want.EncryptedMasterKey, parsed.EncryptedMasterKey)
121+
assert.Equal(t, tt.wantAuth, parsed.AuthSecret)
122+
if tt.wantCompact {
123+
assert.NotEmpty(t, parsed.CompactRaw)
124+
} else {
125+
assert.Empty(t, parsed.CompactRaw)
126+
}
154127
}
155128
})
156129
}

src/server/tests/e2e/test_encryption.py

Lines changed: 17 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@
2424
import asyncpg
2525
import httpx
2626
import pytest
27-
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
2827
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
2928
from cryptography.hazmat.primitives import hashes
29+
from cryptography.hazmat.primitives.keywrap import aes_key_wrap
3030
from pydantic import BaseModel
3131

3232
# Reuse infra from conftest
@@ -52,11 +52,12 @@
5252

5353

5454
# ---------------------------------------------------------------------------
55-
# Crypto helpers — must match Go's DeriveUserKEK / WrapMasterKey
55+
# Crypto helpers — must match Go's compact token format
5656
# ---------------------------------------------------------------------------
5757

5858
KEY_SIZE = 32
59-
NONCE_SIZE = 12
59+
COMPACT_AUTH_SECRET_LEN = 16
60+
COMPACT_VERSION = 0x01
6061

6162

6263
def _derive_user_kek(auth_secret: str, pepper: str) -> bytes:
@@ -68,26 +69,23 @@ def _derive_user_kek(auth_secret: str, pepper: str) -> bytes:
6869
return hkdf.derive(secret)
6970

7071

71-
def _wrap_master_key(wrapping_key: bytes, master_key: bytes) -> str:
72-
"""Encrypt master_key with wrapping_key via AES-256-GCM, return base64url."""
73-
nonce = os.urandom(NONCE_SIZE)
74-
aesgcm = AESGCM(wrapping_key)
75-
ct = aesgcm.encrypt(nonce, master_key, None)
76-
return base64.urlsafe_b64encode(nonce + ct).rstrip(b"=").decode()
77-
78-
7972
def _generate_project_token(pepper: str) -> tuple[str, str, str]:
80-
"""Generate a project token with embedded encrypted master key.
73+
"""Generate a compact project token with AES Key Wrap.
8174
82-
Returns (auth_secret, bearer_token, hmac_hex).
75+
Format: base64url(0x01 | auth_secret_16B | aes_kw(master_key_32B))
76+
Returns (auth_secret_hex, bearer_token, hmac_hex).
8377
"""
84-
auth_secret = base64.urlsafe_b64encode(os.urandom(32)).rstrip(b"=").decode()
78+
auth_secret_raw = os.urandom(COMPACT_AUTH_SECRET_LEN)
79+
auth_secret_hex = auth_secret_raw.hex()
8580
master_key = os.urandom(KEY_SIZE)
86-
wrapping_key = _derive_user_kek(auth_secret, pepper)
87-
encrypted_mk_b64 = _wrap_master_key(wrapping_key, master_key)
88-
bearer_token = f"{TEST_TOKEN_PREFIX}{auth_secret}.{encrypted_mk_b64}"
89-
token_hmac = generate_hmac(auth_secret, pepper)
90-
return auth_secret, bearer_token, token_hmac
81+
wrapping_key = _derive_user_kek(auth_secret_hex, pepper)
82+
wrapped_mk = aes_key_wrap(wrapping_key, master_key)
83+
# Pack: version | auth_secret | wrapped_master_key
84+
buf = bytes([COMPACT_VERSION]) + auth_secret_raw + wrapped_mk
85+
compact_body = base64.urlsafe_b64encode(buf).rstrip(b"=").decode()
86+
bearer_token = f"{TEST_TOKEN_PREFIX}{compact_body}"
87+
token_hmac = generate_hmac(auth_secret_hex, pepper)
88+
return auth_secret_hex, bearer_token, token_hmac
9189

9290

9391
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)