@johpaz/hive-core 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +43 -0
- package/src/agent/compaction.ts +161 -0
- package/src/agent/context-guard.ts +91 -0
- package/src/agent/context.ts +148 -0
- package/src/agent/ethics.ts +102 -0
- package/src/agent/hooks.ts +166 -0
- package/src/agent/index.ts +67 -0
- package/src/agent/providers/index.ts +278 -0
- package/src/agent/providers.ts +1 -0
- package/src/agent/soul.ts +89 -0
- package/src/agent/stuck-loop.ts +133 -0
- package/src/agent/user.ts +86 -0
- package/src/channels/base.ts +91 -0
- package/src/channels/discord.ts +185 -0
- package/src/channels/index.ts +7 -0
- package/src/channels/manager.ts +204 -0
- package/src/channels/slack.ts +209 -0
- package/src/channels/telegram.ts +177 -0
- package/src/channels/webchat.ts +83 -0
- package/src/channels/whatsapp.ts +305 -0
- package/src/config/index.ts +1 -0
- package/src/config/loader.ts +508 -0
- package/src/gateway/index.ts +5 -0
- package/src/gateway/lane-queue.ts +169 -0
- package/src/gateway/router.ts +124 -0
- package/src/gateway/server.ts +347 -0
- package/src/gateway/session.ts +131 -0
- package/src/gateway/slash-commands.ts +176 -0
- package/src/heartbeat/index.ts +157 -0
- package/src/index.ts +21 -0
- package/src/memory/index.ts +1 -0
- package/src/memory/notes.ts +170 -0
- package/src/multi-agent/bindings.ts +171 -0
- package/src/multi-agent/index.ts +4 -0
- package/src/multi-agent/manager.ts +182 -0
- package/src/multi-agent/sandbox.ts +130 -0
- package/src/multi-agent/subagents.ts +302 -0
- package/src/security/index.ts +187 -0
- package/src/tools/cron.ts +156 -0
- package/src/tools/exec.ts +105 -0
- package/src/tools/index.ts +6 -0
- package/src/tools/memory.ts +176 -0
- package/src/tools/notify.ts +53 -0
- package/src/tools/read.ts +154 -0
- package/src/tools/registry.ts +115 -0
- package/src/tools/web.ts +186 -0
- package/src/utils/crypto.ts +73 -0
- package/src/utils/index.ts +3 -0
- package/src/utils/logger.ts +254 -0
- package/src/utils/retry.ts +70 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { Tool } from "./registry.ts";
|
|
4
|
+
|
|
5
|
+
export const readTool: Tool = {
|
|
6
|
+
name: "read",
|
|
7
|
+
description: "Read the contents of a file",
|
|
8
|
+
parameters: {
|
|
9
|
+
type: "object",
|
|
10
|
+
properties: {
|
|
11
|
+
path: {
|
|
12
|
+
type: "string",
|
|
13
|
+
description: "The path to the file to read",
|
|
14
|
+
},
|
|
15
|
+
offset: {
|
|
16
|
+
type: "number",
|
|
17
|
+
description: "Line number to start reading from (1-indexed)",
|
|
18
|
+
},
|
|
19
|
+
limit: {
|
|
20
|
+
type: "number",
|
|
21
|
+
description: "Maximum number of lines to read",
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
required: ["path"],
|
|
25
|
+
},
|
|
26
|
+
execute: async (params: Record<string, unknown>) => {
|
|
27
|
+
const filePath = params.path as string;
|
|
28
|
+
const offset = (params.offset as number) ?? 1;
|
|
29
|
+
const limit = (params.limit as number) ?? 2000;
|
|
30
|
+
|
|
31
|
+
if (!fs.existsSync(filePath)) {
|
|
32
|
+
throw new Error(`File not found: ${filePath}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const stats = fs.statSync(filePath);
|
|
36
|
+
if (stats.isDirectory()) {
|
|
37
|
+
const entries = fs.readdirSync(filePath, { withFileTypes: true });
|
|
38
|
+
return entries.map((e) => ({
|
|
39
|
+
name: e.name,
|
|
40
|
+
type: e.isDirectory() ? "directory" : "file",
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
45
|
+
const lines = content.split("\n");
|
|
46
|
+
|
|
47
|
+
const start = Math.max(0, offset - 1);
|
|
48
|
+
const end = Math.min(lines.length, start + limit);
|
|
49
|
+
|
|
50
|
+
const selected = lines.slice(start, end);
|
|
51
|
+
|
|
52
|
+
return selected
|
|
53
|
+
.map((line, i) => `${start + i + 1}: ${line}`)
|
|
54
|
+
.join("\n");
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const writeTool: Tool = {
|
|
59
|
+
name: "write",
|
|
60
|
+
description: "Write content to a file (creates or overwrites)",
|
|
61
|
+
parameters: {
|
|
62
|
+
type: "object",
|
|
63
|
+
properties: {
|
|
64
|
+
path: {
|
|
65
|
+
type: "string",
|
|
66
|
+
description: "The path to the file to write",
|
|
67
|
+
},
|
|
68
|
+
content: {
|
|
69
|
+
type: "string",
|
|
70
|
+
description: "The content to write to the file",
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
required: ["path", "content"],
|
|
74
|
+
},
|
|
75
|
+
execute: async (params: Record<string, unknown>) => {
|
|
76
|
+
const filePath = params.path as string;
|
|
77
|
+
const content = params.content as string;
|
|
78
|
+
|
|
79
|
+
const dir = path.dirname(filePath);
|
|
80
|
+
if (!fs.existsSync(dir)) {
|
|
81
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
fs.writeFileSync(filePath, content, "utf-8");
|
|
85
|
+
|
|
86
|
+
return { success: true, path: filePath, bytes: content.length };
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const editTool: Tool = {
|
|
91
|
+
name: "edit",
|
|
92
|
+
description: "Edit a file by replacing specific text",
|
|
93
|
+
parameters: {
|
|
94
|
+
type: "object",
|
|
95
|
+
properties: {
|
|
96
|
+
path: {
|
|
97
|
+
type: "string",
|
|
98
|
+
description: "The path to the file to edit",
|
|
99
|
+
},
|
|
100
|
+
oldString: {
|
|
101
|
+
type: "string",
|
|
102
|
+
description: "The text to search for",
|
|
103
|
+
},
|
|
104
|
+
newString: {
|
|
105
|
+
type: "string",
|
|
106
|
+
description: "The text to replace with",
|
|
107
|
+
},
|
|
108
|
+
replaceAll: {
|
|
109
|
+
type: "boolean",
|
|
110
|
+
description: "Replace all occurrences",
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
required: ["path", "oldString", "newString"],
|
|
114
|
+
},
|
|
115
|
+
execute: async (params: Record<string, unknown>) => {
|
|
116
|
+
const filePath = params.path as string;
|
|
117
|
+
const oldString = params.oldString as string;
|
|
118
|
+
const newString = params.newString as string;
|
|
119
|
+
const replaceAll = (params.replaceAll as boolean) ?? false;
|
|
120
|
+
|
|
121
|
+
if (!fs.existsSync(filePath)) {
|
|
122
|
+
throw new Error(`File not found: ${filePath}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
126
|
+
|
|
127
|
+
if (!content.includes(oldString)) {
|
|
128
|
+
throw new Error(`String not found in file: ${oldString.substring(0, 50)}...`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let newContent: string;
|
|
132
|
+
if (replaceAll) {
|
|
133
|
+
newContent = content.split(oldString).join(newString);
|
|
134
|
+
} else {
|
|
135
|
+
const index = content.indexOf(oldString);
|
|
136
|
+
if (index === -1) {
|
|
137
|
+
throw new Error(`String not found in file`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const occurrences = content.split(oldString).length - 1;
|
|
141
|
+
if (occurrences > 1) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`Found ${occurrences} occurrences. Use replaceAll: true or provide more context.`
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
newContent = content.replace(oldString, newString);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
fs.writeFileSync(filePath, newContent, "utf-8");
|
|
151
|
+
|
|
152
|
+
return { success: true, path: filePath };
|
|
153
|
+
},
|
|
154
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { Config } from "../config/loader.ts";
|
|
2
|
+
import { logger } from "../utils/logger.ts";
|
|
3
|
+
|
|
4
|
+
export interface Tool {
|
|
5
|
+
name: string;
|
|
6
|
+
description: string;
|
|
7
|
+
parameters: Record<string, unknown>;
|
|
8
|
+
execute: (params: Record<string, unknown>) => Promise<unknown>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ToolResult {
|
|
12
|
+
success: boolean;
|
|
13
|
+
result?: unknown;
|
|
14
|
+
error?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class ToolRegistry {
|
|
18
|
+
private tools: Map<string, Tool> = new Map();
|
|
19
|
+
private config: Config;
|
|
20
|
+
private log = logger.child("tools");
|
|
21
|
+
|
|
22
|
+
constructor(config: Config) {
|
|
23
|
+
this.config = config;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
register(tool: Tool): void {
|
|
27
|
+
if (this.tools.has(tool.name)) {
|
|
28
|
+
this.log.warn(`Tool ${tool.name} already registered, overwriting`);
|
|
29
|
+
}
|
|
30
|
+
this.tools.set(tool.name, tool);
|
|
31
|
+
this.log.debug(`Registered tool: ${tool.name}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
unregister(name: string): boolean {
|
|
35
|
+
return this.tools.delete(name);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
get(name: string): Tool | undefined {
|
|
39
|
+
return this.tools.get(name);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
has(name: string): boolean {
|
|
43
|
+
return this.tools.has(name);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
list(): Tool[] {
|
|
47
|
+
return Array.from(this.tools.values());
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
isAllowed(name: string, sessionType?: "main" | "dm" | "group"): boolean {
|
|
51
|
+
const deny = this.config.tools?.deny ?? [];
|
|
52
|
+
const allow = this.config.tools?.allow ?? [];
|
|
53
|
+
|
|
54
|
+
if (deny.includes(name)) {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (sessionType === "dm") {
|
|
59
|
+
const dmDeny = this.config.tools?.sandbox?.dm?.deny ?? [];
|
|
60
|
+
if (dmDeny.includes(name)) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (sessionType === "group") {
|
|
66
|
+
const groupDeny = this.config.tools?.sandbox?.group?.deny ?? [];
|
|
67
|
+
if (groupDeny.includes(name)) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (allow.length === 0) {
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return allow.includes(name);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async execute(
|
|
80
|
+
name: string,
|
|
81
|
+
params: Record<string, unknown>,
|
|
82
|
+
sessionType?: "main" | "dm" | "group"
|
|
83
|
+
): Promise<ToolResult> {
|
|
84
|
+
if (!this.isAllowed(name, sessionType)) {
|
|
85
|
+
return {
|
|
86
|
+
success: false,
|
|
87
|
+
error: `Tool ${name} is not allowed for this session type`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const tool = this.tools.get(name);
|
|
92
|
+
if (!tool) {
|
|
93
|
+
return {
|
|
94
|
+
success: false,
|
|
95
|
+
error: `Tool ${name} not found`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
this.log.debug(`Executing tool: ${name}`, { params });
|
|
101
|
+
const result = await tool.execute(params);
|
|
102
|
+
return { success: true, result };
|
|
103
|
+
} catch (error) {
|
|
104
|
+
this.log.error(`Tool ${name} failed: ${(error as Error).message}`);
|
|
105
|
+
return {
|
|
106
|
+
success: false,
|
|
107
|
+
error: (error as Error).message,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function createToolRegistry(config: Config): ToolRegistry {
|
|
114
|
+
return new ToolRegistry(config);
|
|
115
|
+
}
|
package/src/tools/web.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import type { Tool } from "./registry.ts";
|
|
2
|
+
import type { Config } from "../config/loader.ts";
|
|
3
|
+
import { logger } from "../utils/logger.ts";
|
|
4
|
+
import { retry } from "../utils/retry.ts";
|
|
5
|
+
|
|
6
|
+
export interface SearchResult {
|
|
7
|
+
title: string;
|
|
8
|
+
url: string;
|
|
9
|
+
snippet: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createWebSearchTool(config: Config): Tool {
|
|
13
|
+
const webConfig = config.tools?.web ?? {};
|
|
14
|
+
const allowlist = webConfig.allowlist ?? [];
|
|
15
|
+
const denylist = webConfig.denylist ?? ["file://", "ftp://"];
|
|
16
|
+
const timeout = (webConfig.timeoutSeconds ?? 30) * 1000;
|
|
17
|
+
|
|
18
|
+
const log = logger.child("web");
|
|
19
|
+
|
|
20
|
+
const isUrlAllowed = (url: string): boolean => {
|
|
21
|
+
for (const denied of denylist) {
|
|
22
|
+
if (url.startsWith(denied)) return false;
|
|
23
|
+
}
|
|
24
|
+
if (allowlist.length === 0) return true;
|
|
25
|
+
return allowlist.some((allowed) => url.includes(allowed));
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
name: "web_search",
|
|
30
|
+
description: "Search the web for current information",
|
|
31
|
+
parameters: {
|
|
32
|
+
type: "object",
|
|
33
|
+
properties: {
|
|
34
|
+
query: {
|
|
35
|
+
type: "string",
|
|
36
|
+
description: "The search query",
|
|
37
|
+
},
|
|
38
|
+
numResults: {
|
|
39
|
+
type: "number",
|
|
40
|
+
description: "Number of results to return (default: 5)",
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
required: ["query"],
|
|
44
|
+
},
|
|
45
|
+
execute: async (params: Record<string, unknown>) => {
|
|
46
|
+
const query = params.query as string;
|
|
47
|
+
const numResults = (params.numResults as number) ?? 5;
|
|
48
|
+
|
|
49
|
+
log.debug(`Searching: ${query}`);
|
|
50
|
+
|
|
51
|
+
const searchUrl = `https://api.duckduckgo.com/?q=${encodeURIComponent(query)}&format=json&no_html=1`;
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const response = await fetch(searchUrl, {
|
|
55
|
+
signal: AbortSignal.timeout(timeout),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
if (!response.ok) {
|
|
59
|
+
throw new Error(`Search failed: ${response.status}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const data = await response.json() as {
|
|
63
|
+
AbstractText?: string;
|
|
64
|
+
AbstractURL?: string;
|
|
65
|
+
RelatedTopics?: Array<{ Text?: string; FirstURL?: string }>;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const results: SearchResult[] = [];
|
|
69
|
+
|
|
70
|
+
if (data.AbstractText && data.AbstractURL) {
|
|
71
|
+
results.push({
|
|
72
|
+
title: "Summary",
|
|
73
|
+
url: data.AbstractURL,
|
|
74
|
+
snippet: data.AbstractText,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (data.RelatedTopics) {
|
|
79
|
+
for (const topic of data.RelatedTopics.slice(0, numResults - 1)) {
|
|
80
|
+
if (topic.Text && topic.FirstURL && isUrlAllowed(topic.FirstURL)) {
|
|
81
|
+
results.push({
|
|
82
|
+
title: topic.Text.split(" - ")[0] ?? "Result",
|
|
83
|
+
url: topic.FirstURL,
|
|
84
|
+
snippet: topic.Text,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { results, query };
|
|
91
|
+
} catch (error) {
|
|
92
|
+
log.error(`Search failed: ${(error as Error).message}`);
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function createWebFetchTool(config: Config): Tool {
|
|
100
|
+
const webConfig = config.tools?.web ?? {};
|
|
101
|
+
const allowlist = webConfig.allowlist ?? [];
|
|
102
|
+
const denylist = webConfig.denylist ?? ["file://", "ftp://"];
|
|
103
|
+
const timeout = (webConfig.timeoutSeconds ?? 30) * 1000;
|
|
104
|
+
|
|
105
|
+
const log = logger.child("web");
|
|
106
|
+
|
|
107
|
+
const isUrlAllowed = (url: string): boolean => {
|
|
108
|
+
for (const denied of denylist) {
|
|
109
|
+
if (url.startsWith(denied)) return false;
|
|
110
|
+
}
|
|
111
|
+
if (allowlist.length === 0) return true;
|
|
112
|
+
return allowlist.some((allowed) => url.includes(allowed));
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
name: "web_fetch",
|
|
117
|
+
description: "Fetch content from a URL",
|
|
118
|
+
parameters: {
|
|
119
|
+
type: "object",
|
|
120
|
+
properties: {
|
|
121
|
+
url: {
|
|
122
|
+
type: "string",
|
|
123
|
+
description: "The URL to fetch",
|
|
124
|
+
},
|
|
125
|
+
selector: {
|
|
126
|
+
type: "string",
|
|
127
|
+
description: "CSS selector to extract specific content (optional)",
|
|
128
|
+
},
|
|
129
|
+
maxLength: {
|
|
130
|
+
type: "number",
|
|
131
|
+
description: "Maximum characters to return (default: 10000)",
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
required: ["url"],
|
|
135
|
+
},
|
|
136
|
+
execute: async (params: Record<string, unknown>) => {
|
|
137
|
+
const url = params.url as string;
|
|
138
|
+
const maxLength = (params.maxLength as number) ?? 10000;
|
|
139
|
+
|
|
140
|
+
if (!isUrlAllowed(url)) {
|
|
141
|
+
throw new Error(`URL not allowed: ${url}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
log.debug(`Fetching: ${url}`);
|
|
145
|
+
|
|
146
|
+
return retry(
|
|
147
|
+
async () => {
|
|
148
|
+
const response = await fetch(url, {
|
|
149
|
+
signal: AbortSignal.timeout(timeout),
|
|
150
|
+
headers: {
|
|
151
|
+
"User-Agent": "Mozilla/5.0 (compatible; Hive/0.1)",
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
if (!response.ok) {
|
|
156
|
+
throw new Error(`Fetch failed: ${response.status}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
160
|
+
let content: string;
|
|
161
|
+
|
|
162
|
+
if (contentType.includes("application/json")) {
|
|
163
|
+
const json = await response.json();
|
|
164
|
+
content = JSON.stringify(json, null, 2);
|
|
165
|
+
} else {
|
|
166
|
+
content = await response.text();
|
|
167
|
+
|
|
168
|
+
content = content
|
|
169
|
+
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
|
|
170
|
+
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
|
|
171
|
+
.replace(/<[^>]+>/g, " ")
|
|
172
|
+
.replace(/\s+/g, " ")
|
|
173
|
+
.trim();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (content.length > maxLength) {
|
|
177
|
+
content = content.slice(0, maxLength) + "\n... (truncated)";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return { content, url, contentType };
|
|
181
|
+
},
|
|
182
|
+
{ maxAttempts: 2, initialDelayMs: 1000 }
|
|
183
|
+
);
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import * as crypto from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export function generateId(): string {
|
|
4
|
+
return crypto.randomUUID();
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function generateShortId(length = 8): string {
|
|
8
|
+
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
9
|
+
let result = "";
|
|
10
|
+
const randomBytes = crypto.randomBytes(length);
|
|
11
|
+
|
|
12
|
+
for (let i = 0; i < length; i++) {
|
|
13
|
+
result += chars[randomBytes[i]! % chars.length];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return result;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function hashString(input: string): string {
|
|
20
|
+
return crypto.createHash("sha256").update(input).digest("hex");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function hashObject(obj: unknown): string {
|
|
24
|
+
return hashString(JSON.stringify(obj));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function hmacSign(
|
|
28
|
+
key: string,
|
|
29
|
+
data: string,
|
|
30
|
+
algorithm: "sha256" | "sha512" = "sha256"
|
|
31
|
+
): Promise<string> {
|
|
32
|
+
return crypto.createHmac(algorithm, key).update(data).digest("hex");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function hmacVerify(
|
|
36
|
+
key: string,
|
|
37
|
+
data: string,
|
|
38
|
+
signature: string,
|
|
39
|
+
algorithm: "sha256" | "sha512" = "sha256"
|
|
40
|
+
): Promise<boolean> {
|
|
41
|
+
const expected = await hmacSign(key, data, algorithm);
|
|
42
|
+
return crypto.timingSafeEqual(
|
|
43
|
+
Buffer.from(expected),
|
|
44
|
+
Buffer.from(signature)
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function encrypt(text: string, key: string): string {
|
|
49
|
+
const iv = crypto.randomBytes(16);
|
|
50
|
+
const derivedKey = crypto.scryptSync(key, "salt", 32);
|
|
51
|
+
const cipher = crypto.createCipheriv("aes-256-cbc", derivedKey, iv);
|
|
52
|
+
|
|
53
|
+
let encrypted = cipher.update(text, "utf8", "hex");
|
|
54
|
+
encrypted += cipher.final("hex");
|
|
55
|
+
|
|
56
|
+
return iv.toString("hex") + ":" + encrypted;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function decrypt(encryptedData: string, key: string): string {
|
|
60
|
+
const [ivHex, encrypted] = encryptedData.split(":");
|
|
61
|
+
if (!ivHex || !encrypted) {
|
|
62
|
+
throw new Error("Invalid encrypted data format");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const iv = Buffer.from(ivHex, "hex");
|
|
66
|
+
const derivedKey = crypto.scryptSync(key, "salt", 32);
|
|
67
|
+
const decipher = crypto.createDecipheriv("aes-256-cbc", derivedKey, iv);
|
|
68
|
+
|
|
69
|
+
let decrypted = decipher.update(encrypted, "hex", "utf8");
|
|
70
|
+
decrypted += decipher.final("utf8");
|
|
71
|
+
|
|
72
|
+
return decrypted;
|
|
73
|
+
}
|