dsh-session-cloud 0.1.0
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/cordis.patch.yml +9 -0
- package/lib/client.js +1912 -0
- package/lib/client.js.map +7 -0
- package/lib/config.d.ts +60 -0
- package/lib/config.js +61 -0
- package/lib/crypto/envelope.d.ts +62 -0
- package/lib/crypto/envelope.js +162 -0
- package/lib/dsh-types.d.ts +69 -0
- package/lib/dsh-types.js +1 -0
- package/lib/index.d.ts +11 -0
- package/lib/index.js +485 -0
- package/lib/status.d.ts +17 -0
- package/lib/status.js +65 -0
- package/lib/sync/engine.d.ts +85 -0
- package/lib/sync/engine.js +379 -0
- package/lib/sync/frames.d.ts +35 -0
- package/lib/sync/frames.js +121 -0
- package/lib/sync/http-client.d.ts +44 -0
- package/lib/sync/http-client.js +87 -0
- package/lib/sync/kdf.d.ts +14 -0
- package/lib/sync/kdf.js +102 -0
- package/lib/sync/restore.d.ts +136 -0
- package/lib/sync/restore.js +295 -0
- package/lib/sync/state.d.ts +89 -0
- package/lib/sync/state.js +117 -0
- package/package.json +62 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-cloud-sync 加密信封(protocol v1,见 docs/03-server-protocol.md §5)。
|
|
3
|
+
*
|
|
4
|
+
* 字节布局:
|
|
5
|
+
* 数据对象:magic "DCS1"(4) + kdf salt(16) + 加密段*,段顺序即段序号
|
|
6
|
+
* 每段: 段密文长度 L uint32BE(4) + nonce(12) + 密文(L) + GCM tag(16)
|
|
7
|
+
* 元数据对象:magic + salt + 单个段(AAD "meta|<sessionId>")
|
|
8
|
+
*
|
|
9
|
+
* 链式 AAD:log 段 i 的 AAD = "log|<sessionId>|<i>|<hex(prevTag)>",
|
|
10
|
+
* i=0 时 prevTag 为 32 个字符 "0"。截断、重排、跨对象拼接均解密失败。
|
|
11
|
+
*/
|
|
12
|
+
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:crypto';
|
|
13
|
+
export const MAGIC = Buffer.from('DCS1');
|
|
14
|
+
export const SALT_LENGTH = 16;
|
|
15
|
+
export const HEADER_LENGTH = MAGIC.length + SALT_LENGTH;
|
|
16
|
+
export const NONCE_LENGTH = 12;
|
|
17
|
+
export const TAG_LENGTH = 16;
|
|
18
|
+
const LENGTH_FIELD = 4;
|
|
19
|
+
/** 段固定开销:长度字段 + nonce + tag */
|
|
20
|
+
export const SEGMENT_OVERHEAD = LENGTH_FIELD + NONCE_LENGTH + TAG_LENGTH;
|
|
21
|
+
export const KDF_DEFAULTS = { N: 2 ** 15, r: 8, p: 1 };
|
|
22
|
+
export function generateSalt() {
|
|
23
|
+
return randomBytes(SALT_LENGTH);
|
|
24
|
+
}
|
|
25
|
+
export function deriveKey(passphrase, salt, params = KDF_DEFAULTS) {
|
|
26
|
+
if (salt.length !== SALT_LENGTH)
|
|
27
|
+
throw new Error(`salt must be ${SALT_LENGTH} bytes`);
|
|
28
|
+
// Node 默认 maxmem=32MB 不够 N=2^15,r=8(需 128*N*r*p ≈ 32MB 外加余量)
|
|
29
|
+
const maxmem = Math.max(64 * 1024 * 1024, 256 * params.N * params.r * params.p);
|
|
30
|
+
return scryptSync(passphrase, salt, 32, { N: params.N, r: params.r, p: params.p, maxmem });
|
|
31
|
+
}
|
|
32
|
+
export function encodeKdfSidecar(salt, params = KDF_DEFAULTS) {
|
|
33
|
+
return Buffer.from(JSON.stringify({ salt: salt.toString('hex'), ...params }), 'utf8');
|
|
34
|
+
}
|
|
35
|
+
export function decodeKdfSidecar(bytes) {
|
|
36
|
+
const parsed = JSON.parse(bytes.toString('utf8'));
|
|
37
|
+
const salt = Buffer.from(parsed.salt, 'hex');
|
|
38
|
+
if (salt.length !== SALT_LENGTH)
|
|
39
|
+
throw new Error('invalid salt in kdf sidecar');
|
|
40
|
+
return { salt, params: { N: parsed.N, r: parsed.r, p: parsed.p } };
|
|
41
|
+
}
|
|
42
|
+
const ZERO_TAG_HEX = '0'.repeat(TAG_LENGTH * 2);
|
|
43
|
+
function logAad(sessionId, index, prevTagHex) {
|
|
44
|
+
return Buffer.from(`log|${sessionId}|${index}|${prevTagHex}`, 'utf8');
|
|
45
|
+
}
|
|
46
|
+
function metaAad(sessionId) {
|
|
47
|
+
return Buffer.from(`meta|${sessionId}`, 'utf8');
|
|
48
|
+
}
|
|
49
|
+
function sealSegment(key, nonce, aad, plaintext) {
|
|
50
|
+
const cipher = createCipheriv('aes-256-gcm', key, nonce);
|
|
51
|
+
cipher.setAAD(aad);
|
|
52
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
53
|
+
const tag = cipher.getAuthTag();
|
|
54
|
+
const header = Buffer.alloc(LENGTH_FIELD + NONCE_LENGTH);
|
|
55
|
+
header.writeUInt32BE(ciphertext.length, 0);
|
|
56
|
+
nonce.copy(header, LENGTH_FIELD);
|
|
57
|
+
return Buffer.concat([header, ciphertext, tag]);
|
|
58
|
+
}
|
|
59
|
+
function openSegment(key, aad, segment) {
|
|
60
|
+
if (segment.length < SEGMENT_OVERHEAD)
|
|
61
|
+
throw new Error('truncated segment');
|
|
62
|
+
const length = segment.readUInt32BE(0);
|
|
63
|
+
if (segment.length !== SEGMENT_OVERHEAD + length) {
|
|
64
|
+
throw new Error(`segment length mismatch: field=${length}, actual=${segment.length - SEGMENT_OVERHEAD}`);
|
|
65
|
+
}
|
|
66
|
+
const nonce = segment.subarray(LENGTH_FIELD, LENGTH_FIELD + NONCE_LENGTH);
|
|
67
|
+
const ciphertext = segment.subarray(LENGTH_FIELD + NONCE_LENGTH, LENGTH_FIELD + NONCE_LENGTH + length);
|
|
68
|
+
const tag = segment.subarray(segment.length - TAG_LENGTH);
|
|
69
|
+
const decipher = createDecipheriv('aes-256-gcm', key, nonce);
|
|
70
|
+
decipher.setAAD(aad);
|
|
71
|
+
decipher.setAuthTag(tag);
|
|
72
|
+
try {
|
|
73
|
+
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
74
|
+
return { plaintext, tag: Buffer.from(tag) };
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
throw new Error('segment authentication failed');
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function checkHeader(bytes, context) {
|
|
81
|
+
if (bytes.length < HEADER_LENGTH)
|
|
82
|
+
throw new Error(`${context}: object too short`);
|
|
83
|
+
if (!bytes.subarray(0, MAGIC.length).equals(MAGIC))
|
|
84
|
+
throw new Error(`${context}: bad magic`);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* 数据对象的增量加密器。一次上传 = `append(完整 zstd 帧区间)` 产出一个段,
|
|
88
|
+
* 段字节直接 PUT append 到服务端对象尾部。
|
|
89
|
+
*/
|
|
90
|
+
export class LogEncryptor {
|
|
91
|
+
key;
|
|
92
|
+
salt;
|
|
93
|
+
sessionId;
|
|
94
|
+
index;
|
|
95
|
+
prevTagHex;
|
|
96
|
+
constructor(key, salt, sessionId, startIndex = 0, prevTag) {
|
|
97
|
+
this.key = key;
|
|
98
|
+
this.salt = salt;
|
|
99
|
+
this.sessionId = sessionId;
|
|
100
|
+
if (salt.length !== SALT_LENGTH)
|
|
101
|
+
throw new Error(`salt must be ${SALT_LENGTH} bytes`);
|
|
102
|
+
this.index = startIndex;
|
|
103
|
+
this.prevTagHex = prevTag ? prevTag.toString('hex') : ZERO_TAG_HEX;
|
|
104
|
+
}
|
|
105
|
+
/** 新对象的文件头(magic + salt),仅在首传时写入一次。 */
|
|
106
|
+
objectHeader() {
|
|
107
|
+
return Buffer.concat([MAGIC, this.salt]);
|
|
108
|
+
}
|
|
109
|
+
get segmentIndex() {
|
|
110
|
+
return this.index;
|
|
111
|
+
}
|
|
112
|
+
get lastTag() {
|
|
113
|
+
return Buffer.from(this.prevTagHex, 'hex');
|
|
114
|
+
}
|
|
115
|
+
/** 加密一段明文(完整 zstd 帧的字节区间),返回可追加的段字节。 */
|
|
116
|
+
append(plaintext) {
|
|
117
|
+
const aad = logAad(this.sessionId, this.index, this.prevTagHex);
|
|
118
|
+
const segment = sealSegment(this.key, randomBytes(NONCE_LENGTH), aad, plaintext);
|
|
119
|
+
this.prevTagHex = segment.subarray(segment.length - TAG_LENGTH).toString('hex');
|
|
120
|
+
this.index += 1;
|
|
121
|
+
return segment;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/** 解密完整数据对象(恢复路径):校验 magic/salt,逐段链式解密。 */
|
|
125
|
+
export function decryptLog(key, sessionId, bytes) {
|
|
126
|
+
checkHeader(bytes, 'log');
|
|
127
|
+
const parts = [];
|
|
128
|
+
let offset = HEADER_LENGTH;
|
|
129
|
+
let index = 0;
|
|
130
|
+
let prevTagHex = ZERO_TAG_HEX;
|
|
131
|
+
while (offset < bytes.length) {
|
|
132
|
+
if (bytes.length - offset < SEGMENT_OVERHEAD)
|
|
133
|
+
throw new Error('truncated: incomplete trailing segment');
|
|
134
|
+
const length = bytes.readUInt32BE(offset);
|
|
135
|
+
const end = offset + SEGMENT_OVERHEAD + length;
|
|
136
|
+
if (end > bytes.length)
|
|
137
|
+
throw new Error('truncated: segment extends past end of object');
|
|
138
|
+
const { plaintext, tag } = openSegment(key, logAad(sessionId, index, prevTagHex), bytes.subarray(offset, end));
|
|
139
|
+
parts.push(plaintext);
|
|
140
|
+
prevTagHex = tag.toString('hex');
|
|
141
|
+
index += 1;
|
|
142
|
+
offset = end;
|
|
143
|
+
}
|
|
144
|
+
return Buffer.concat(parts);
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* 409 幂等判定(见 docs/02 §10):下载服务端末段,以预期 AAD 校验解密。
|
|
148
|
+
* 返回明文供与本地比对;任何不匹配抛错。
|
|
149
|
+
*/
|
|
150
|
+
export function verifyTailSegment(key, sessionId, segmentIndex, prevTag, segmentBytes) {
|
|
151
|
+
const prevTagHex = prevTag ? prevTag.toString('hex') : ZERO_TAG_HEX;
|
|
152
|
+
return openSegment(key, logAad(sessionId, segmentIndex, prevTagHex), segmentBytes).plaintext;
|
|
153
|
+
}
|
|
154
|
+
export function encryptMeta(key, salt, sessionId, meta) {
|
|
155
|
+
const plaintext = Buffer.from(JSON.stringify(meta), 'utf8');
|
|
156
|
+
return Buffer.concat([MAGIC, salt, sealSegment(key, randomBytes(NONCE_LENGTH), metaAad(sessionId), plaintext)]);
|
|
157
|
+
}
|
|
158
|
+
export function decryptMeta(key, sessionId, bytes) {
|
|
159
|
+
checkHeader(bytes, 'meta');
|
|
160
|
+
const { plaintext } = openSegment(key, metaAad(sessionId), bytes.subarray(HEADER_LENGTH));
|
|
161
|
+
return JSON.parse(plaintext.toString('utf8'));
|
|
162
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/** @deepseek-ai/dsh-session-persistence 的 SessionHeader(packages/core/session/src/types.ts:56)。 */
|
|
2
|
+
export interface SessionHeaderLike {
|
|
3
|
+
readonly version: number;
|
|
4
|
+
readonly id: string;
|
|
5
|
+
readonly createdAt: number;
|
|
6
|
+
readonly cwd?: string;
|
|
7
|
+
readonly parentSession?: string;
|
|
8
|
+
readonly seedLength?: number;
|
|
9
|
+
readonly origin?: 'subagent';
|
|
10
|
+
readonly delegationDepth?: number;
|
|
11
|
+
readonly agentPreset?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface SessionSnapshotLike {
|
|
14
|
+
readonly header: SessionHeaderLike;
|
|
15
|
+
/** 后端持有的不透明 revision 令牌,变更必变 */
|
|
16
|
+
readonly revision: string;
|
|
17
|
+
}
|
|
18
|
+
/** locate() 的返回;无单文件 artifact 的后端(如 SQLite)返回 undefined。 */
|
|
19
|
+
export interface SessionLocationLike {
|
|
20
|
+
readonly kind: string;
|
|
21
|
+
readonly path: string;
|
|
22
|
+
}
|
|
23
|
+
export interface SessionPersistenceLike {
|
|
24
|
+
listSnapshots(signal?: AbortSignal): Promise<SessionSnapshotLike[]>;
|
|
25
|
+
list(signal?: AbortSignal): Promise<SessionHeaderLike[]>;
|
|
26
|
+
locate(meta: SessionHeaderLike): SessionLocationLike | undefined;
|
|
27
|
+
}
|
|
28
|
+
/** @deepseek-ai/dsh-workspace 的 Workspace(恢复挂载用到 attachSession)。 */
|
|
29
|
+
export interface WorkspaceLike {
|
|
30
|
+
readonly path: string;
|
|
31
|
+
/** 把会话记入本工作区成员(cwd 校验:realpath 后须等于工作区 path) */
|
|
32
|
+
attachSession(sessionId: string): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
export interface WorkspaceRegistryLike {
|
|
35
|
+
list(): WorkspaceLike[];
|
|
36
|
+
/** 按本机 canonical path 查找已有 workspace;不存在返回 undefined */
|
|
37
|
+
resolveByPath(path: string): Promise<WorkspaceLike | undefined>;
|
|
38
|
+
/** 为已存在目录新建 workspace(realpath + isDirectory 校验,目录不存在会抛) */
|
|
39
|
+
create(path: string, title?: string): Promise<WorkspaceLike>;
|
|
40
|
+
}
|
|
41
|
+
/** settings 服务的最小切面(@deepseek-ai/dsh-settings 的 installSection)。 */
|
|
42
|
+
export interface SettingsSectionHooksLike<T> {
|
|
43
|
+
setSource(source: () => T): void;
|
|
44
|
+
onChange(): void;
|
|
45
|
+
validate?(value: T): void;
|
|
46
|
+
}
|
|
47
|
+
export interface SettingsLike {
|
|
48
|
+
installSection<T>(owner: unknown, ns: string, schema: unknown, entry: T, hooks: SettingsSectionHooksLike<T>): void;
|
|
49
|
+
/** merge 写 user 层并持久化;Host 状态回写走这条通道(settings/src/index.ts:562) */
|
|
50
|
+
update(ns: string, patch: object): Promise<void>;
|
|
51
|
+
}
|
|
52
|
+
declare module '@deepseek-ai/cordis' {
|
|
53
|
+
interface Context {
|
|
54
|
+
sessionPersistence: SessionPersistenceLike;
|
|
55
|
+
settings?: SettingsLike;
|
|
56
|
+
workspaceRegistry?: WorkspaceRegistryLike;
|
|
57
|
+
dshHomePath?: (...segments: string[]) => string;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* dsh 的 durability checkpoint 事件(core/session Events,包只做结构声明):
|
|
61
|
+
* 缓冲事件落盘时触发,插件用它做 flush 驱动的变更发现(M5,见 sync/engine
|
|
62
|
+
* requestScan)。真实载荷是 Session 对象,插件只关心触发时机。
|
|
63
|
+
*/
|
|
64
|
+
interface Events {
|
|
65
|
+
'session/flush'(session: {
|
|
66
|
+
id: string;
|
|
67
|
+
}): void;
|
|
68
|
+
}
|
|
69
|
+
}
|
package/lib/dsh-types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-cloud-sync Host 半入口:settings 注册、state 打开、kdf 引导、引擎生命周期。
|
|
3
|
+
* 同步逻辑全部在 ./sync/engine.ts,这里只做与 cordis 的接线。
|
|
4
|
+
*/
|
|
5
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
6
|
+
import { Config, StatusConfig, type Config as ConfigT } from './config.js';
|
|
7
|
+
export declare const name = "cloud-sync";
|
|
8
|
+
export declare const inject: string[];
|
|
9
|
+
export { Config, StatusConfig };
|
|
10
|
+
export declare function configKey(cfg: ConfigT): string;
|
|
11
|
+
export declare function apply(ctx: Context, config: ConfigT): Promise<() => Promise<void>>;
|