Compare commits

..
Author SHA1 Message Date
Philipinho 9f5a7d76d9 feat(server): add global encryption module (AES-256-GCM)
Provides an injectable EncryptionService that encrypts/decrypts strings
with AES-256-GCM using a key derived from APP_SECRET with domain
separation. Payloads are base64 JSON envelopes with validated IV and
auth tag lengths, and failures surface as typed errors.
2026-08-13 21:44:09 +01:00
20 changed files with 375 additions and 442 deletions
@@ -1,12 +0,0 @@
import { UnstyledButton } from "@mantine/core";
import { type ComponentPropsWithoutRef, forwardRef } from "react";
// Menu.Item hard-codes role="menuitem"; use as its `component` to restore role="menuitemcheckbox" so aria-checked works.
export const CheckboxMenuItem = forwardRef<
HTMLButtonElement,
ComponentPropsWithoutRef<"button">
>((props, ref) => (
<UnstyledButton ref={ref} {...props} role="menuitemcheckbox" />
));
CheckboxMenuItem.displayName = "CheckboxMenuItem";
@@ -39,7 +39,6 @@ export function useWorkspaceLabelsQuery(query: string, enabled: boolean) {
queryFn: () => getWorkspaceLabels({ type: "page", query, limit: 50 }),
enabled,
staleTime: 30 * 1000,
placeholderData: keepPreviousData
});
}
@@ -1,120 +0,0 @@
import { ReactNode, useState } from "react";
import { Divider, Group, Menu, ScrollArea, Text, TextInput } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { IconCheck, IconSearch } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query";
import { RadioMenuItem } from "@/components/ui/radio-menu-item";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { IUser } from "@/features/user/types/user.types.ts";
type CreatorFilterMenuProps = {
value: string | null;
onChange: (user: IUser | null) => void;
children: ReactNode;
width?: number;
position?:
| "bottom-start"
| "bottom-end"
| "bottom"
| "top-start"
| "top-end"
| "top";
zIndex?: number;
};
export function CreatorFilterMenu({
value,
onChange,
children,
width = 280,
position = "bottom-end",
zIndex,
}: CreatorFilterMenuProps) {
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(searchQuery, 300);
const { data: suggestion, isLoading } = useSearchSuggestionsQuery({
query: debouncedQuery,
includeUsers: true,
includeGroups: false,
includePages: false,
preload: true,
});
const users: IUser[] = (suggestion?.users as IUser[]) ?? [];
return (
<Menu shadow="md" width={width} position={position} zIndex={zIndex}>
<Menu.Target>{children}</Menu.Target>
<Menu.Dropdown>
<TextInput
placeholder={t("Find a user")}
data-autofocus
autoFocus
leftSection={<IconSearch size={16} />}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
size="sm"
variant="filled"
radius="sm"
styles={{ input: { marginBottom: 8 } }}
/>
<ScrollArea.Autosize mah={280}>
<Menu.Item
component={RadioMenuItem}
aria-checked={!value}
onClick={() => onChange(null)}
>
<Group flex="1" gap="xs">
<div style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{t("Anyone")}
</Text>
</div>
{!value && <IconCheck size={20} aria-hidden />}
</Group>
</Menu.Item>
<Divider my="xs" />
{users.length === 0 && (
<Text size="xs" c="dimmed" px="xs" py="sm">
{isLoading ? t("Loading...") : t("No users found")}
</Text>
)}
{users.map((user) => (
<Menu.Item
key={user.id}
component={RadioMenuItem}
aria-checked={value === user.id}
onClick={() => onChange(user)}
>
<Group flex="1" gap="xs">
<CustomAvatar
avatarUrl={user.avatarUrl}
size={20}
name={user.name}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
{user.name}
</Text>
{user.email && (
<Text size="xs" c="dimmed" truncate>
{user.email}
</Text>
)}
</div>
{value === user.id && <IconCheck size={20} aria-hidden />}
</Group>
</Menu.Item>
))}
</ScrollArea.Autosize>
</Menu.Dropdown>
</Menu>
);
}
@@ -1,121 +0,0 @@
import { ReactNode, useMemo, useState } from "react";
import {
Group,
Menu,
ScrollArea,
Text,
TextInput,
useComputedColorScheme,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { IconCheck, IconSearch } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useWorkspaceLabelsQuery } from "@/features/label/queries/label-query.ts";
import { getLabelColor } from "@/features/label/utils/label-colors.ts";
import { CheckboxMenuItem } from "@/components/ui/checkbox-menu-item";
type LabelFilterMenuProps = {
value: string[];
onChange: (labelIds: string[]) => void;
children: ReactNode;
width?: number;
position?:
| "bottom-start"
| "bottom-end"
| "bottom"
| "top-start"
| "top-end"
| "top";
zIndex?: number;
};
export function LabelFilterMenu({
value,
onChange,
children,
width = 280,
position = "bottom-end",
zIndex,
}: LabelFilterMenuProps) {
const { t } = useTranslation();
const scheme = useComputedColorScheme("light");
const [searchQuery, setSearchQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(searchQuery, 300);
const { data, isLoading } = useWorkspaceLabelsQuery(debouncedQuery, true);
const labels = data?.items ?? [];
const selectedSet = useMemo(() => new Set(value), [value]);
const toggleLabel = (labelId: string) => {
if (selectedSet.has(labelId)) {
onChange(value.filter((id) => id !== labelId));
} else {
onChange([...value, labelId]);
}
};
return (
<Menu
shadow="md"
width={width}
position={position}
zIndex={zIndex}
closeOnItemClick={false}
>
<Menu.Target>{children}</Menu.Target>
<Menu.Dropdown>
<TextInput
placeholder={t("Find a label")}
data-autofocus
autoFocus
leftSection={<IconSearch size={16} />}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
size="sm"
variant="filled"
radius="sm"
styles={{ input: { marginBottom: 8 } }}
/>
<ScrollArea.Autosize mah={280}>
{labels.length === 0 && (
<Text size="xs" c="dimmed" px="xs" py="sm">
{isLoading ? t("Loading...") : t("No labels found")}
</Text>
)}
{labels.map((label) => {
const isChecked = selectedSet.has(label.id);
const color = getLabelColor(label.name, scheme);
return (
<Menu.Item
key={label.id}
type="button"
component={CheckboxMenuItem}
aria-checked={isChecked}
onClick={() => toggleLabel(label.id)}
>
<Group flex="1" gap="xs">
<span
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: color.dot,
flexShrink: 0,
}}
/>
<Text size="sm" fw={500} style={{ flex: 1 }} truncate>
{label.name}
</Text>
{isChecked && <IconCheck size={20} aria-hidden />}
</Group>
</Menu.Item>
);
})}
</ScrollArea.Autosize>
</Menu.Dropdown>
</Menu>
);
}
@@ -13,20 +13,16 @@ import {
IconBuilding,
IconFileDescription,
IconCheck,
IconUser,
IconTag,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import { SpaceFilterMenu } from "@/features/space/components/space-filter-menu";
import { CreatorFilterMenu } from "@/features/search/components/creator-filter-menu";
import { RadioMenuItem } from "@/components/ui/radio-menu-item";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import classes from "./search-spotlight-filters.module.css";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { LabelFilterMenu } from "./label-filter-menu";
interface SearchSpotlightFiltersProps {
onFiltersChange?: (filters: any) => void;
@@ -44,14 +40,9 @@ export function SearchSpotlightFilters({
const { t } = useTranslation();
const hasAttachmentIndexing = useHasFeature(Feature.ATTACHMENT_INDEXING);
const [selectedSpaceId, setSelectedSpaceId] = useState<string | null>(
spaceId || null
spaceId || null,
);
const [contentType, setContentType] = useState<string | null>("page");
const [selectedCreatorId, setSelectedCreatorId] = useState<string | null>(null);
const [selectedCreatorName, setSelectedCreatorName] = useState<string | null>(
null
);
const [selectedLabelIds, setSelectedLabelIds] = useState<string[]>([]);
const [workspace] = useAtom(workspaceAtom);
const { data: spacesData } = useGetSpacesQuery({ limit: 100 });
@@ -59,6 +50,15 @@ export function SearchSpotlightFilters({
? spacesData?.items.find((space) => space.id === selectedSpaceId)
: null;
useEffect(() => {
if (onFiltersChange) {
onFiltersChange({
spaceId: selectedSpaceId,
contentType,
});
}
}, []);
const contentTypeOptions = [
{ value: "page", label: t("Pages") },
{
@@ -68,39 +68,37 @@ export function SearchSpotlightFilters({
},
];
useEffect(() => {
onFiltersChange?.({
spaceId: selectedSpaceId,
contentType,
creatorId: selectedCreatorId,
labelIds: selectedLabelIds,
});
}, [
selectedSpaceId,
contentType,
selectedCreatorId,
selectedLabelIds,
onFiltersChange,
]);
const handleSpaceSelect = (spaceId: string | null) => {
setSelectedSpaceId(spaceId);
if (onFiltersChange) {
onFiltersChange({
spaceId: spaceId,
contentType,
});
}
};
const handleCreatorSelect = (user: { id: string; name: string } | null) => {
setSelectedCreatorId(user?.id ?? null);
setSelectedCreatorName(user?.name ?? null);
};
const handleFilterChange = (filterType: string, value: any) => {
let newSelectedSpaceId = selectedSpaceId;
let newContentType = contentType;
const handleLabelsSelect = (labelIds: string[]) => {
setSelectedLabelIds(labelIds);
};
switch (filterType) {
case "spaceId":
newSelectedSpaceId = value;
setSelectedSpaceId(value);
break;
case "contentType":
newContentType = value;
setContentType(value);
break;
}
const handleChangeContentType = (value: string) => {
setContentType(value);
if (value === "attachment") {
setSelectedLabelIds([]);
if (onFiltersChange) {
onFiltersChange({
spaceId: newSelectedSpaceId,
contentType: newContentType,
});
}
};
@@ -124,17 +122,8 @@ export function SearchSpotlightFilters({
color="blue"
labelPosition="left"
styles={{
root: {
display: "flex",
alignItems: "center",
flexShrink: 0,
},
label: {
whiteSpace: "nowrap",
paddingRight: "8px",
fontSize: "13px",
fontWeight: 500,
},
root: { display: "flex", alignItems: "center" },
label: { paddingRight: "8px", fontSize: "13px", fontWeight: 500 },
}}
/>
</div>
@@ -192,7 +181,7 @@ export function SearchSpotlightFilters({
onClick={() =>
!option.disabled &&
contentType !== option.value &&
handleChangeContentType(option.value)
handleFilterChange("contentType", option.value)
}
disabled={
option.disabled || (isAiMode && option.value === "attachment")
@@ -206,11 +195,13 @@ export function SearchSpotlightFilters({
{t("Enterprise")}
</Badge>
)}
{!option.disabled && isAiMode && option.value === "attachment" && (
<Text size="xs" mt={4}>
{t("AI Answers not available for attachments")}
</Text>
)}
{!option.disabled &&
isAiMode &&
option.value === "attachment" && (
<Text size="xs" mt={4}>
{t("AI Answers not available for attachments")}
</Text>
)}
</div>
{contentType === option.value && <IconCheck size={20} aria-hidden />}
</Group>
@@ -218,52 +209,6 @@ export function SearchSpotlightFilters({
))}
</Menu.Dropdown>
</Menu>
<CreatorFilterMenu
value={selectedCreatorId}
onChange={handleCreatorSelect}
position="bottom-start"
width={250}
zIndex={getDefaultZIndex("max")}
>
<Button
variant="subtle"
color="gray"
size="sm"
rightSection={<IconChevronDown size={14} />}
leftSection={<IconUser size={16} />}
className={classes.filterButton}
fw={500}
>
{selectedCreatorId
? `${t("Created by")}: ${selectedCreatorName || t("Unknown")}`
: `${t("Created by")}: ${t("Anyone")}`}
</Button>
</CreatorFilterMenu>
{contentType !== "attachment" && (
<LabelFilterMenu
value={selectedLabelIds}
onChange={handleLabelsSelect}
position="bottom-start"
width={250}
zIndex={getDefaultZIndex("max")}
>
<Button
variant="subtle"
color="gray"
size="sm"
rightSection={<IconChevronDown size={14} />}
leftSection={<IconTag size={16} />}
className={classes.filterButton}
fw={500}
>
{selectedLabelIds.length > 0
? `${t("Labels")} (${selectedLabelIds.length})`
: t("Labels")}
</Button>
</LabelFilterMenu>
)}
</div>
);
}
@@ -1,7 +1,7 @@
import { Spotlight } from "@mantine/spotlight";
import { IconSearch, IconSparkles } from "@tabler/icons-react";
import { Group, Button, VisuallyHidden, Text } from "@mantine/core";
import React, { useState, useMemo, useEffect, useCallback } from "react";
import { Group, Button, VisuallyHidden } from "@mantine/core";
import React, { useState, useMemo, useEffect } from "react";
import { useDebouncedValue } from "@mantine/hooks";
import { useTranslation } from "react-i18next";
import { notifications } from "@mantine/notifications";
@@ -26,8 +26,6 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
const [filters, setFilters] = useState<{
spaceId?: string | null;
contentType?: string;
creatorId?: string | null;
labelIds?: string[];
}>({
contentType: "page",
});
@@ -45,21 +43,10 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
params.spaceId = filters.spaceId;
}
if (filters.creatorId) {
params.creatorId = filters.creatorId;
}
if (filters.labelIds?.length) {
params.labelIds = filters.labelIds;
}
return params;
}, [debouncedSearchQuery, filters]);
const {
data: searchResults,
isFetching,
} = useUnifiedSearch(
const { data: searchResults, isLoading } = useUnifiedSearch(
searchParams,
!isAiMode // Disable regular search when in AI mode
);
@@ -109,9 +96,9 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
/>
));
const handleFiltersChange = useCallback((newFilters: any) => {
const handleFiltersChange = (newFilters: any) => {
setFilters(newFilters);
}, [setFilters]);
};
const handleAskClick = () => {
setIsAiMode(!isAiMode);
@@ -180,7 +167,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
? query.length > 0 && !isAiLoading && !aiSearchResult
? t("No answer available")
: ""
: query.length > 0 && !isFetching
: query.length > 0 && !isLoading
? resultItems.length === 0
? t("No results found")
: t("{{count}} results found", { count: resultItems.length })
@@ -211,19 +198,11 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
<Spotlight.Empty>{t("Start typing to search...")}</Spotlight.Empty>
)}
{query.length > 0 && !isFetching && resultItems.length === 0 && (
{query.length > 0 && !isLoading && resultItems.length === 0 && (
<Spotlight.Empty>{t("No results found...")}</Spotlight.Empty>
)}
{resultItems.length > 0 && <>{resultItems}</>}
{query.length > 0 && isFetching && (
<Spotlight.Empty>
<Text size="sm" style={{ marginTop: 10 }}>
{t("Searching...")}
</Text>
</Spotlight.Empty>
)}
</>
)}
</Spotlight.ActionsList>
@@ -1,4 +1,4 @@
import { keepPreviousData, useQuery, UseQueryResult } from "@tanstack/react-query";
import { useQuery, UseQueryResult } from "@tanstack/react-query";
import {
searchPage,
searchAttachments,
@@ -40,6 +40,5 @@ export function useUnifiedSearch(
}
},
enabled: !!params.query && enabled,
placeholderData: params.query.length > 0 ? keepPreviousData: undefined
});
}
@@ -36,8 +36,6 @@ export interface IPageSearchParams {
query: string;
spaceId?: string;
shareId?: string;
creatorId?: string;
labelIds?: string[];
}
export interface IAttachmentSearch {
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from "react";
import React, { useEffect, useState } from "react";
import { useDebouncedValue } from "@mantine/hooks";
import { Group, Select, SelectProps, Text } from "@mantine/core";
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
@@ -14,7 +14,6 @@ interface SpaceSelectProps {
width?: number;
opened?: boolean;
clearable?: boolean;
withinPortal?: boolean;
}
const renderSelectOption: SelectProps["renderOption"] = ({ option }) => (
@@ -42,7 +41,6 @@ export function SpaceSelect({
width,
opened,
clearable,
withinPortal = true,
}: SpaceSelectProps) {
const { t } = useTranslation();
const [searchValue, setSearchValue] = useState("");
@@ -52,13 +50,9 @@ export function SpaceSelect({
limit: 50,
});
const [data, setData] = useState([]);
const fetchedSpaces = useRef(new Map<string, ISpace>());
useEffect(() => {
if (spaces) {
spaces.items.forEach((space: ISpace) =>
fetchedSpaces.current.set(space.slug, space),
);
const spaceData = spaces?.items
.filter((space: ISpace) => space.slug !== value)
.map((space: ISpace) => {
@@ -89,19 +83,14 @@ export function SpaceSelect({
onSearchChange={setSearchValue}
clearable={clearable}
variant="filled"
onChange={(slug) => {
// options accumulate across fetches; resolve against everything
// fetched, not just the latest query result
const space = slug && fetchedSpaces.current.get(slug);
if (space) {
onChange(space);
}
}}
onChange={(slug) =>
onChange(spaces.items?.find((item) => item.slug === slug))
}
onClick={(e) => e.stopPropagation()}
nothingFoundMessage={t("No space found")}
limit={50}
checkIconPosition="right"
comboboxProps={{ width, withinPortal, position: "bottom", keepMounted: false, dropdownPadding: 0 }}
comboboxProps={{ width, withinPortal: true, position: "bottom", keepMounted: false, dropdownPadding: 0 }}
dropdownOpened={opened}
/>
);
@@ -70,7 +70,6 @@ export function SwitchSpace({
onChange={(space) => handleSelect(space.slug)}
width={300}
opened={true}
withinPortal={false}
/>
</Popover.Dropdown>
</Popover>
+2
View File
@@ -27,6 +27,7 @@ import { LoggerModule } from './common/logger/logger.module';
import { ClsModule } from 'nestjs-cls';
import { NoopAuditModule } from './integrations/audit/audit.module';
import { ThrottleModule } from './integrations/throttle/throttle.module';
import { EncryptionModule } from './integrations/encryption/encryption.module';
const enterpriseModules = [];
try {
@@ -53,6 +54,7 @@ try {
CoreModule,
DatabaseModule,
EnvironmentModule,
EncryptionModule,
RedisModule.forRootAsync({
useClass: RedisConfigService,
}),
@@ -7,15 +7,12 @@ import { executeTx } from '@docmost/db/utils';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { normalizeLabelName } from './utils';
import { EventEmitter2 } from "@nestjs/event-emitter";
import { EventName } from "src/common/events/event.contants";
@Injectable()
export class LabelService {
constructor(
private readonly labelRepo: LabelRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly eventEmitter: EventEmitter2,
@InjectKysely() private readonly db: KyselyDB,
) {}
@@ -37,12 +34,6 @@ export class LabelService {
attached.push(label);
}
});
this.eventEmitter.emit(EventName.PAGE_UPDATED, {
pageIds: [pageId],
workspaceId: workspaceId,
});
return attached;
}
@@ -73,11 +64,6 @@ export class LabelService {
await this.labelRepo.deleteLabel(labelId, workspaceId, trx);
}
});
this.eventEmitter.emit(EventName.PAGE_UPDATED, {
pageIds: [pageId],
workspaceId: workspaceId,
});
}
async getPageLabels(pageId: string, pagination: PaginationOptions) {
@@ -1,5 +1,4 @@
import {
IsArray,
IsBoolean,
IsNotEmpty,
IsNumber,
@@ -25,11 +24,6 @@ export class SearchDTO {
@IsUUID()
creatorId?: string;
@IsOptional()
@IsArray()
@IsUUID('all', { each: true })
labelIds?: string[];
@IsOptional()
@IsNumber()
limit?: number;
@@ -35,7 +35,6 @@ export class SearchService {
return { items: [] };
}
const searchQuery = tsquery(query.trim() + '*');
const labelIds = [...new Set(searchParams.labelIds ?? [])];
let queryResults = this.db
.selectFrom('pages')
@@ -63,22 +62,6 @@ export class SearchService {
.$if(Boolean(searchParams.creatorId), (qb) =>
qb.where('creatorId', '=', searchParams.creatorId),
)
.$if(labelIds?.length > 0, (qb) =>
qb.where(
'id',
'in',
this.db
.selectFrom('pageLabels')
.select('pageId')
.where('labelId', 'in', labelIds)
.groupBy('pageId')
.having(
sql<number>`count(distinct "label_id")`,
'=',
labelIds.length,
),
),
)
.where('deletedAt', 'is', null)
.orderBy('rank', 'desc')
.limit(searchParams.limit || 25)
-1
View File
@@ -312,7 +312,6 @@ export interface PageHistory {
export interface Pages {
content: Json | null;
contributorIds: Generated<string[] | null>;
labelIds: Generated<string[] | null>;
coverPhoto: string | null;
createdAt: Generated<Timestamp>;
creatorId: string | null;
@@ -0,0 +1,13 @@
export class UnableToInitialize extends Error {
constructor(message: string) {
super(`Unable to initialize the encryption service: ${message}`);
this.name = 'UnableToInitialize';
}
}
export class UnableToDecrypt extends Error {
constructor(reason: string) {
super(`Unable to decrypt the ciphertext: ${reason}`);
this.name = 'UnableToDecrypt';
}
}
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { EncryptionService } from './encryption.service';
@Global()
@Module({
providers: [EncryptionService],
exports: [EncryptionService],
})
export class EncryptionModule {}
@@ -0,0 +1,184 @@
import { Test, TestingModule } from '@nestjs/testing';
import { EncryptionService } from './encryption.service';
import { UnableToDecrypt, UnableToInitialize } from './encryption.errors';
import { EnvironmentService } from '../environment/environment.service';
const APP_SECRET = 'test-app-secret-with-plenty-of-entropy-1234567890';
const buildService = (appSecret: string | undefined) => {
const env = { getAppSecret: () => appSecret } as EnvironmentService;
return new EncryptionService(env);
};
const decodeEnvelope = (encrypted: string) =>
JSON.parse(Buffer.from(encrypted, 'base64').toString()) as {
iv: string;
authTag: string;
cipherText: string;
};
const encodeEnvelope = (envelope: {
iv: string;
authTag: string;
cipherText: string;
}) => Buffer.from(JSON.stringify(envelope)).toString('base64');
describe('EncryptionService', () => {
let service: EncryptionService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
EncryptionService,
{
provide: EnvironmentService,
useValue: { getAppSecret: () => APP_SECRET },
},
],
}).compile();
service = module.get<EncryptionService>(EncryptionService);
});
describe('initialization', () => {
it('compiles via Nest DI', () => {
expect(service).toBeDefined();
});
it('throws UnableToInitialize when APP_SECRET is missing', () => {
expect(() => buildService(undefined)).toThrow(UnableToInitialize);
expect(() => buildService('')).toThrow(UnableToInitialize);
});
});
describe('encrypt + decrypt round-trip', () => {
it('decrypts back to the original plaintext', () => {
const plaintext = 'hello world';
const encrypted = service.encrypt(plaintext);
expect(service.decrypt(encrypted)).toBe(plaintext);
});
it('handles empty string', () => {
const encrypted = service.encrypt('');
expect(service.decrypt(encrypted)).toBe('');
});
it('handles unicode (multi-byte UTF-8)', () => {
const plaintext = 'héllo 🔐 世界';
const encrypted = service.encrypt(plaintext);
expect(service.decrypt(encrypted)).toBe(plaintext);
});
it('handles long plaintext (>1 block)', () => {
const plaintext = 'a'.repeat(10_000);
const encrypted = service.encrypt(plaintext);
expect(service.decrypt(encrypted)).toBe(plaintext);
});
it('produces distinct ciphertexts for the same plaintext (random IV)', () => {
const plaintext = 'same input';
const a = service.encrypt(plaintext);
const b = service.encrypt(plaintext);
expect(a).not.toBe(b);
expect(service.decrypt(a)).toBe(plaintext);
expect(service.decrypt(b)).toBe(plaintext);
});
});
describe('cross-key isolation', () => {
it('cannot decrypt ciphertext produced under a different APP_SECRET', () => {
const other = buildService('totally-different-secret-value-9876543210');
const encrypted = service.encrypt('secret');
expect(() => other.decrypt(encrypted)).toThrow(UnableToDecrypt);
});
});
describe('tamper detection', () => {
it('rejects modified ciphertext', () => {
const encrypted = service.encrypt('hello');
const env = decodeEnvelope(encrypted);
const tamperedCipher = Buffer.from(env.cipherText, 'base64');
tamperedCipher[0] ^= 0x01;
const tampered = encodeEnvelope({
...env,
cipherText: tamperedCipher.toString('base64'),
});
expect(() => service.decrypt(tampered)).toThrow(UnableToDecrypt);
});
it('rejects modified auth tag', () => {
const encrypted = service.encrypt('hello');
const env = decodeEnvelope(encrypted);
const tamperedTag = Buffer.from(env.authTag, 'base64');
tamperedTag[0] ^= 0x01;
const tampered = encodeEnvelope({
...env,
authTag: tamperedTag.toString('base64'),
});
expect(() => service.decrypt(tampered)).toThrow(UnableToDecrypt);
});
it('rejects modified IV', () => {
const encrypted = service.encrypt('hello');
const env = decodeEnvelope(encrypted);
const tamperedIV = Buffer.from(env.iv, 'base64');
tamperedIV[0] ^= 0x01;
const tampered = encodeEnvelope({
...env,
iv: tamperedIV.toString('base64'),
});
expect(() => service.decrypt(tampered)).toThrow(UnableToDecrypt);
});
});
describe('malformed payloads', () => {
it('rejects non-base64 garbage', () => {
expect(() => service.decrypt('!!!not-valid-base64!!!')).toThrow(
UnableToDecrypt,
);
});
it('rejects base64 of non-JSON', () => {
const garbage = Buffer.from('not json at all').toString('base64');
expect(() => service.decrypt(garbage)).toThrow(UnableToDecrypt);
});
it('rejects JSON missing required fields', () => {
const partial = encodeEnvelope({
iv: Buffer.alloc(12).toString('base64'),
authTag: Buffer.alloc(16).toString('base64'),
} as never);
expect(() => service.decrypt(partial)).toThrow(UnableToDecrypt);
});
it('rejects wrong-length IV', () => {
const encrypted = service.encrypt('hello');
const env = decodeEnvelope(encrypted);
const bad = encodeEnvelope({
...env,
iv: Buffer.alloc(8).toString('base64'),
});
expect(() => service.decrypt(bad)).toThrow(UnableToDecrypt);
});
it('rejects wrong-length auth tag', () => {
const encrypted = service.encrypt('hello');
const env = decodeEnvelope(encrypted);
const bad = encodeEnvelope({
...env,
authTag: Buffer.alloc(8).toString('base64'),
});
expect(() => service.decrypt(bad)).toThrow(UnableToDecrypt);
});
});
describe('envelope format', () => {
it('returns base64 of JSON envelope with iv (12B), authTag (16B), cipherText', () => {
const encrypted = service.encrypt('hello');
const env = decodeEnvelope(encrypted);
expect(Buffer.from(env.iv, 'base64')).toHaveLength(12);
expect(Buffer.from(env.authTag, 'base64')).toHaveLength(16);
expect(Buffer.from(env.cipherText, 'base64').length).toBeGreaterThan(0);
});
});
});
@@ -0,0 +1,108 @@
// https://github.com/nhedger/nestjs-encryption - MIT
import { Injectable } from '@nestjs/common';
import {
createCipheriv,
createDecipheriv,
createHash,
randomBytes,
} from 'node:crypto';
import { UnableToDecrypt, UnableToInitialize } from './encryption.errors';
import { EnvironmentService } from '../environment/environment.service';
const ALGORITHM = 'aes-256-gcm';
const KEY_DOMAIN = 'docmost:encryption:v1';
const IV_LENGTH = 12;
const AUTH_TAG_LENGTH = 16;
type AEADPayload<TFormat = string | Buffer> = {
iv: TFormat;
authTag: TFormat;
cipherText: TFormat;
};
@Injectable()
export class EncryptionService {
private readonly key: Buffer;
constructor(environmentService: EnvironmentService) {
const appSecret = environmentService.getAppSecret();
if (!appSecret) {
throw new UnableToInitialize('APP_SECRET is not set.');
}
this.key = createHash('sha256')
.update(KEY_DOMAIN)
.update(appSecret)
.digest();
}
public encrypt(plaintext: string): string {
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, this.key, iv);
const cipherText = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.final(),
]);
const authTag = cipher.getAuthTag();
const aead: AEADPayload<string> = {
iv: iv.toString('base64'),
authTag: authTag.toString('base64'),
cipherText: cipherText.toString('base64'),
};
return Buffer.from(JSON.stringify(aead)).toString('base64');
}
public decrypt(encrypted: string): string {
try {
const { iv, authTag, cipherText } = this.decodeAEADPayload(encrypted);
const decipher = createDecipheriv(ALGORITHM, this.key, iv);
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([
decipher.update(cipherText),
decipher.final(),
]);
return decrypted.toString('utf8');
} catch (e: unknown) {
throw new UnableToDecrypt((e as Error).message);
}
}
private decodeAEADPayload(encodedPayload: string): AEADPayload<Buffer> {
const payload = Buffer.from(encodedPayload, 'base64');
let deserializedPkg: Record<string, unknown>;
try {
deserializedPkg = JSON.parse(payload.toString());
} catch {
throw new Error('The decoded AEAD payload is not a valid JSON string.');
}
for (const field of ['iv', 'authTag', 'cipherText']) {
if (!Object.prototype.hasOwnProperty.call(deserializedPkg, field)) {
throw new Error(`The AEAD payload is missing the ${field} field.`);
}
}
const iv = Buffer.from(deserializedPkg.iv as string, 'base64');
if (iv.length !== IV_LENGTH) {
throw new Error(
`The decoded IV is not the correct length. Expected ${IV_LENGTH} bytes, got ${iv.length} bytes.`,
);
}
const authTag = Buffer.from(deserializedPkg.authTag as string, 'base64');
if (authTag.length !== AUTH_TAG_LENGTH) {
throw new Error(
`The decoded auth tag is not the correct length. Expected ${AUTH_TAG_LENGTH} bytes, got ${authTag.length} bytes.`,
);
}
const cipherText = Buffer.from(
deserializedPkg.cipherText as string,
'base64',
);
return { iv, authTag, cipherText };
}
}