Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ _cgo_export.*
_obj/
_test/
.gocache/
.worktrees/

# ─── Node / TypeScript (sdk, cli) ─────────────────────────────────────────────
**/node_modules/
Expand Down
2 changes: 1 addition & 1 deletion gateway/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ All configuration is via CLI flags or `REP_GATEWAY_*` environment variables. Fla
| `--log-level` | `REP_GATEWAY_LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` |
| `--allowed-origins` | `REP_GATEWAY_ALLOWED_ORIGINS` | (empty) | CORS origins for session key endpoint |
| `--session-key-ttl` | `REP_GATEWAY_SESSION_KEY_TTL` | `30s` | Session key time-to-live |
| `--session-key-max-rate` | `REP_GATEWAY_SESSION_KEY_MAX_RATE` | `10` | Max session key requests/min/IP |
| `--session-key-max-rate` | `REP_GATEWAY_SESSION_KEY_MAX_RATE` | `15` | Max session key requests/min/IP |
| `--health-port` | `REP_GATEWAY_HEALTH_PORT` | `0` | Separate health check port (0 = same) |
| `--version` | — | — | Print version and exit |

Expand Down
2 changes: 1 addition & 1 deletion gateway/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ func Parse(args []string, version string) (*Config, error) {
defaultHotReloadMode := "signal"
defaultPollInterval := "30s"
defaultSessionTTL := "30s"
defaultSessionMaxRate := 10
defaultSessionMaxRate := 15
defaultStrict := false
var defaultAllowedOrigins string

Expand Down
4 changes: 2 additions & 2 deletions gateway/internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ func TestParse_Defaults(t *testing.T) {
if cfg.LogFormat != "json" {
t.Errorf("expected log-format=json, got %s", cfg.LogFormat)
}
if cfg.SessionKeyMaxRate != 10 {
t.Errorf("expected session-key-max-rate=10, got %d", cfg.SessionKeyMaxRate)
if cfg.SessionKeyMaxRate != 15 {
t.Errorf("expected session-key-max-rate=15, got %d", cfg.SessionKeyMaxRate)
}
}

Expand Down
2 changes: 1 addition & 1 deletion gateway/internal/manifest/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,7 @@ func defaultSettings() *Settings {
HotReloadMode: "signal",
HotReloadPollInterval: 30 * time.Second,
SessionKeyTTL: 30 * time.Second,
SessionKeyMaxRate: 10,
SessionKeyMaxRate: 15,
}
}

Expand Down
43 changes: 43 additions & 0 deletions sdk/src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,49 @@ describe('getSecure()', () => {
const { getSecure } = await import('../index');
await expect(getSecure('KEY')).rejects.toThrow('500');
});

it('coalesces concurrent session key requests', async () => {
injectPayload(
makePayload({}, { sensitive: 'dGVzdA==', keyEndpoint: '/rep/session-key' })
);
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
status: 429,
statusText: 'Too Many Requests',
});
vi.stubGlobal('fetch', fetchMock);

const { getSecure } = await import('../index');

await Promise.allSettled([getSecure('A'), getSecure('B'), getSecure('C')]);

expect(fetchMock).toHaveBeenCalledTimes(1);
});

it('retries after a failed coalesced request', async () => {
injectPayload(
makePayload({}, { sensitive: 'dGVzdA==', keyEndpoint: '/rep/session-key' })
);
const fetchMock = vi
.fn()
.mockResolvedValueOnce({
ok: false,
status: 429,
statusText: 'Too Many Requests',
})
.mockResolvedValueOnce({
ok: false,
status: 500,
statusText: 'Internal Server Error',
});
vi.stubGlobal('fetch', fetchMock);

const { getSecure } = await import('../index');

await expect(Promise.all([getSecure('A'), getSecure('B')])).rejects.toThrow('429');
await expect(getSecure('A')).rejects.toThrow('500');
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});

// ─── onChange() ──────────────────────────────────────────────────────────────
Expand Down
94 changes: 56 additions & 38 deletions sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ let _publicVars: Readonly<Record<string, string>> = Object.freeze({});

// Cache for decrypted sensitive variables (in-memory only, never persisted).
let _sensitiveCache: Record<string, string> | null = null;
let _sensitiveLoadPromise: Promise<Record<string, string>> | null = null;

// Hot reload state.
let _eventSource: EventSource | null = null;
Expand Down Expand Up @@ -187,56 +188,73 @@ export async function getSecure(key: string): Promise<string> {
return _sensitiveCache[key];
}

// Fetch session key from the gateway.
const resp = await fetch(_payload._meta.key_endpoint);
if (!resp.ok) {
throw new REPError(`Session key request failed: ${resp.status} ${resp.statusText}`);
if (!_sensitiveLoadPromise) {
_sensitiveLoadPromise = _loadSensitiveVars();
}

const sessionKey: SessionKeyResponse = await resp.json();
const sensitiveMap = await _sensitiveLoadPromise;

// Decode the encryption key.
const rawKey = Uint8Array.from(atob(sessionKey.key), (c) => c.charCodeAt(0));
if (!(key in sensitiveMap)) {
throw new REPError(`SENSITIVE variable "${key}" not found in payload.`);
}

// Decode the encrypted blob.
const blobBytes = Uint8Array.from(atob(_payload.sensitive), (c) => c.charCodeAt(0));
return sensitiveMap[key];
}

async function _loadSensitiveVars(): Promise<Record<string, string>> {
try {
if (!_payload || !_payload.sensitive || !_payload._meta.key_endpoint) {
throw new REPError('No SENSITIVE tier variables in payload.');
}

// Extract nonce (first 12 bytes) and ciphertext+tag (rest).
const nonce = blobBytes.slice(0, 12);
const ciphertext = blobBytes.slice(12);
// Fetch session key from the gateway.
const resp = await fetch(_payload._meta.key_endpoint);
if (!resp.ok) {
throw new REPError(`Session key request failed: ${resp.status} ${resp.statusText}`);
}

// Decrypt using Web Crypto API (AES-256-GCM).
const cryptoKey = await crypto.subtle.importKey(
'raw',
rawKey,
{ name: 'AES-GCM' },
false,
['decrypt']
);
const sessionKey: SessionKeyResponse = await resp.json();

// The AAD is the preliminary integrity token (see payload builder).
// For simplicity in the SDK, we use the integrity from _meta.
// This matches the gateway's encryption AAD.
const encoder = new TextEncoder();
const aad = encoder.encode(_payload._meta.integrity);
// Decode the encryption key.
const rawKey = Uint8Array.from(atob(sessionKey.key), (c) => c.charCodeAt(0));

const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: nonce, additionalData: aad },
cryptoKey,
ciphertext
);
// Decode the encrypted blob.
const blobBytes = Uint8Array.from(atob(_payload.sensitive), (c) => c.charCodeAt(0));

const decoder = new TextDecoder();
const sensitiveMap: Record<string, string> = JSON.parse(decoder.decode(plaintext));
// Extract nonce (first 12 bytes) and ciphertext+tag (rest).
const nonce = blobBytes.slice(0, 12);
const ciphertext = blobBytes.slice(12);

// Cache all decrypted values.
_sensitiveCache = sensitiveMap;
// Decrypt using Web Crypto API (AES-256-GCM).
const cryptoKey = await crypto.subtle.importKey(
'raw',
rawKey,
{ name: 'AES-GCM' },
false,
['decrypt']
);

if (!(key in sensitiveMap)) {
throw new REPError(`SENSITIVE variable "${key}" not found in payload.`);
// The AAD is the preliminary integrity token (see payload builder).
// For simplicity in the SDK, we use the integrity from _meta.
// This matches the gateway's encryption AAD.
const encoder = new TextEncoder();
const aad = encoder.encode(_payload._meta.integrity);

const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: nonce, additionalData: aad },
cryptoKey,
ciphertext
);

const decoder = new TextDecoder();
const sensitiveMap: Record<string, string> = JSON.parse(decoder.decode(plaintext));

// Cache all decrypted values.
_sensitiveCache = sensitiveMap;
return sensitiveMap;
} finally {
_sensitiveLoadPromise = null;
}

return sensitiveMap[key];
}

/**
Expand Down
Loading