Skip to content
Merged
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
293 changes: 292 additions & 1 deletion packages/gateway/src/auth/settings/agent-settings-store.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,27 @@
import {
type AuthProfile,
BaseRedisStore,
decrypt,
encrypt,
type InstalledProvider,
type McpServerConfig,
type NetworkConfig,
type NixConfig,
type PluginsConfig,
type SkillsConfig,
safeJsonParse,
safeJsonStringify,
type ToolsConfig,
} from "@lobu/core";
import type Redis from "ioredis";

const ENCRYPTED_VALUE_PREFIX = "enc:v1:";

interface SensitiveValueDecodeResult {
value: string;
needsMigration: boolean;
}

/**
* Agent settings - configurable per agentId via web UI
* Stored in Redis at agent:settings:{agentId}
Expand Down Expand Up @@ -59,12 +70,16 @@ export interface AgentSettings {
* - Explicit (from channel binding): any custom agentId
*/
export class AgentSettingsStore extends BaseRedisStore<AgentSettings> {
private readonly encryptionAvailable: boolean;

constructor(redis: Redis) {
super({
redis,
keyPrefix: "agent:settings",
loggerName: "agent-settings-store",
});

this.encryptionAvailable = this.canEncryptSensitiveValues();
}

/**
Expand All @@ -73,7 +88,33 @@ export class AgentSettingsStore extends BaseRedisStore<AgentSettings> {
*/
async getSettings(agentId: string): Promise<AgentSettings | null> {
const key = this.buildKey(agentId);
return this.get(key);
try {
const data = await this.redis.get(key);
if (!data) {
return null;
}

const parsed = safeJsonParse<AgentSettings>(data);
if (!parsed) {
this.logger.warn("Failed to parse agent settings from Redis", { key });
return null;
}

const { settings, needsMigration } =
this.decryptSettingsForRuntime(parsed);

if (needsMigration) {
await this.migrateSettingsInPlace(key, settings, data);
}

return settings;
} catch (error) {
this.logger.error("Failed to get settings from Redis", {
error: error instanceof Error ? error.message : String(error),
key,
});
return null;
}
}

/**
Expand Down Expand Up @@ -127,4 +168,254 @@ export class AgentSettingsStore extends BaseRedisStore<AgentSettings> {
const key = this.buildKey(agentId);
return this.exists(key);
}

protected override serialize(value: AgentSettings): string {
const encrypted = this.encryptSettingsForStorage(value);
const json = safeJsonStringify(encrypted);
if (json === null) {
throw new Error("Failed to serialize value to JSON");
}
return json;
}

private canEncryptSensitiveValues(): boolean {
try {
const probe = "agent-settings-store-encryption-probe";
return decrypt(encrypt(probe)) === probe;
} catch {
this.logger.warn(
"ENCRYPTION_KEY not configured or invalid - auth profile credentials will be stored unencrypted"
);
return false;
}
}

private async migrateSettingsInPlace(
key: string,
settings: AgentSettings,
originalRaw: string
): Promise<void> {
if (!this.encryptionAvailable) {
return;
}

try {
await this.redis.watch(key);
const currentRaw = await this.redis.get(key);
if (currentRaw !== originalRaw) {
await this.redis.unwatch();
this.logger.info(
"Skipped credentials migration due to concurrent settings update",
{ key }
);
return;
}

const serialized = this.serialize(settings);
const result = await this.redis.multi().set(key, serialized).exec();
if (!result) {
this.logger.info(
"Skipped credentials migration due to concurrent settings update",
{ key }
);
return;
}

this.logger.info(
"Migrated agent settings credentials to encrypted format",
{
key,
}
);
} catch (error) {
try {
await this.redis.unwatch();
} catch {
// Ignore cleanup failures.
}
this.logger.warn(
"Failed migrating plaintext agent settings credentials",
{
error: error instanceof Error ? error.message : String(error),
key,
}
);
}
}

private encryptSettingsForStorage(settings: AgentSettings): AgentSettings {
if (
!Array.isArray(settings.authProfiles) ||
settings.authProfiles.length === 0
) {
return settings;
}

let changed = false;
const authProfiles = settings.authProfiles.map((profile) => {
const encryptedCredential = this.encryptSensitiveValue(
profile.credential
);
const credentialChanged = encryptedCredential !== profile.credential;
let metadataChanged = false;
let metadata = profile.metadata;

if (profile.metadata?.refreshToken) {
const encryptedRefreshToken = this.encryptSensitiveValue(
profile.metadata.refreshToken
);
if (encryptedRefreshToken !== profile.metadata.refreshToken) {
metadataChanged = true;
metadata = {
...profile.metadata,
refreshToken: encryptedRefreshToken,
};
}
}

if (!credentialChanged && !metadataChanged) {
return profile;
}

changed = true;
return {
...profile,
credential: encryptedCredential,
metadata,
};
});

if (!changed) {
return settings;
}

return {
...settings,
authProfiles,
};
}

private decryptSettingsForRuntime(settings: AgentSettings): {
settings: AgentSettings;
needsMigration: boolean;
} {
if (
!Array.isArray(settings.authProfiles) ||
settings.authProfiles.length === 0
) {
return { settings, needsMigration: false };
}

let changed = false;
let needsMigration = false;

const authProfiles = settings.authProfiles.map((profile) => {
const credential = this.decryptSensitiveValue(profile.credential);
let metadata = profile.metadata;
let metadataChanged = false;

if (profile.metadata?.refreshToken) {
const refreshToken = this.decryptSensitiveValue(
profile.metadata.refreshToken
);
if (refreshToken.value !== profile.metadata.refreshToken) {
metadataChanged = true;
metadata = {
...profile.metadata,
refreshToken: refreshToken.value,
};
}
needsMigration ||= refreshToken.needsMigration;
}

if (credential.value !== profile.credential || metadataChanged) {
changed = true;
}

needsMigration ||= credential.needsMigration;

if (!metadataChanged && credential.value === profile.credential) {
return profile;
}

return {
...profile,
credential: credential.value,
metadata,
};
});

if (!changed) {
return { settings, needsMigration };
}

return {
settings: {
...settings,
authProfiles,
},
needsMigration,
};
}

private encryptSensitiveValue(value: string): string {
if (!this.encryptionAvailable) {
return value;
}
if (value.startsWith(ENCRYPTED_VALUE_PREFIX)) {
return value;
}
return `${ENCRYPTED_VALUE_PREFIX}${encrypt(value)}`;
}

private decryptSensitiveValue(value: string): SensitiveValueDecodeResult {
if (value.startsWith(ENCRYPTED_VALUE_PREFIX)) {
if (!this.encryptionAvailable) {
return { value, needsMigration: false };
}

try {
const decrypted = decrypt(value.slice(ENCRYPTED_VALUE_PREFIX.length));
return { value: decrypted, needsMigration: false };
} catch (error) {
this.logger.warn("Failed to decrypt auth profile credential value", {
error: error instanceof Error ? error.message : String(error),
});
return { value, needsMigration: false };
}
}

if (this.encryptionAvailable && this.looksLikeLegacyEncryptedValue(value)) {
try {
const decrypted = decrypt(value);
return { value: decrypted, needsMigration: true };
} catch (error) {
this.logger.warn(
"Failed to decrypt legacy auth profile credential value",
{
error: error instanceof Error ? error.message : String(error),
}
);
return { value, needsMigration: false };
}
}

return {
value,
needsMigration: this.encryptionAvailable,
};
Comment on lines +403 to +406
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid migrating undecryptable legacy credentials

When a value matches the legacy encrypted shape but decrypt(...) fails (for example after key rotation or temporary key misconfiguration), the fallback path returns needsMigration: true, so getSettings will rewrite that ciphertext as if it were plaintext. That irreversibly destroys the original token material and forces re-authentication even if the correct old key is restored later.

Useful? React with 👍 / 👎.

}

private looksLikeLegacyEncryptedValue(value: string): boolean {
const [iv, tag, encrypted, ...rest] = value.split(":");
if (rest.length > 0) return false;
if (!iv || !tag || !encrypted) return false;
return (
iv.length === 24 &&
tag.length === 32 &&
/^[0-9a-f]+$/i.test(iv) &&
/^[0-9a-f]+$/i.test(tag) &&
/^[0-9a-f]+$/i.test(encrypted)
);
}
}
Loading