dscs-server 0.0.0-stage → 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/LICENSE +661 -0
- package/README.md +39 -2
- package/dist/blobs.js +50 -0
- package/dist/db.js +333 -0
- package/dist/http.js +1281 -0
- package/dist/index.js +170 -0
- package/dist/leases.js +43 -0
- package/dist/sessions.js +369 -0
- package/dist/shared/batches.js +230 -0
- package/dist/shared/blob-batch.js +85 -0
- package/dist/shared/chain.js +152 -0
- package/dist/shared/crypto.js +70 -0
- package/dist/shared/frames.js +102 -0
- package/dist/shared/index.js +12 -0
- package/dist/shared/protocol.js +14 -0
- package/dist/shared/sealed-frame.js +155 -0
- package/dist/shared/vault.js +210 -0
- package/dist/shared/wire.js +25 -0
- package/dist/vault.js +147 -0
- package/package.json +43 -4
- package/web/admin.html +895 -0
- package/web/setup.html +591 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { zstdCompressSync, zstdDecompressSync } from 'node:zlib';
|
|
2
|
+
import { createCipheriv, createDecipheriv } from 'node:crypto';
|
|
3
|
+
import { deriveSubkey, sha256, TAG_BYTES, KEY_BYTES, NONCE_BYTES } from './crypto.js';
|
|
4
|
+
export const SEALED_MAGIC = Buffer.from('DSHF', 'ascii');
|
|
5
|
+
export const SEALED_VERSION = 1;
|
|
6
|
+
export const SEALED_VERSION_ZSTD = 2;
|
|
7
|
+
export const KEY_ID_BYTES = 8;
|
|
8
|
+
export const SEALED_HEADER_BYTES = 4 + 1 + 1 + KEY_ID_BYTES + NONCE_BYTES;
|
|
9
|
+
const FIXED_NONCE = Buffer.alloc(NONCE_BYTES);
|
|
10
|
+
export function sessionKeyOf(master, sessionId) {
|
|
11
|
+
return deriveSubkey(master, `v1|session:${sessionId}`);
|
|
12
|
+
}
|
|
13
|
+
export function frameKeyOf(sessionKey, chainHash, compression = 'none', compressedHash) {
|
|
14
|
+
if (compression === 'zstd') {
|
|
15
|
+
if (compressedHash === undefined || !/^[0-9a-f]{64}$/u.test(compressedHash)) {
|
|
16
|
+
throw new Error('zstd 帧缺少合法的 compressedHash');
|
|
17
|
+
}
|
|
18
|
+
return deriveSubkey(sessionKey, `v2|frame:zstd:${compressedHash}:${chainHash}`);
|
|
19
|
+
}
|
|
20
|
+
return deriveSubkey(sessionKey, `v1|frame:${chainHash}`);
|
|
21
|
+
}
|
|
22
|
+
export function keyIdOf(sessionKey) {
|
|
23
|
+
return deriveSubkey(sessionKey, 'v1|key-id').subarray(0, KEY_ID_BYTES);
|
|
24
|
+
}
|
|
25
|
+
const SEQ_HEADER = 0xffffffff;
|
|
26
|
+
function buildAad(header, position) {
|
|
27
|
+
const session = Buffer.from(position.sessionId, 'utf8');
|
|
28
|
+
if (session.length > 0xffff) throw new Error('会话 id 过长,AAD 无法编码');
|
|
29
|
+
const tail = Buffer.alloc(2 + session.length + 8 + 4 + 4 + 32);
|
|
30
|
+
tail.writeUInt16BE(session.length, 0);
|
|
31
|
+
session.copy(tail, 2);
|
|
32
|
+
let offset = 2 + session.length;
|
|
33
|
+
tail.writeBigUInt64BE(BigInt(position.index), offset);
|
|
34
|
+
offset += 8;
|
|
35
|
+
tail.writeUInt32BE(position.seqFrom < 0 ? SEQ_HEADER : position.seqFrom, offset);
|
|
36
|
+
offset += 4;
|
|
37
|
+
tail.writeUInt32BE(position.seqTo < 0 ? SEQ_HEADER : position.seqTo, offset);
|
|
38
|
+
offset += 4;
|
|
39
|
+
Buffer.from(position.chainHash, 'hex').copy(tail, offset);
|
|
40
|
+
return Buffer.concat([
|
|
41
|
+
header,
|
|
42
|
+
tail
|
|
43
|
+
]);
|
|
44
|
+
}
|
|
45
|
+
export function sealFrame(plain, position, sessionKey, options = {}) {
|
|
46
|
+
if (sessionKey.length !== KEY_BYTES) throw new Error(`会话密钥必须是 ${KEY_BYTES} 字节`);
|
|
47
|
+
if (!Number.isSafeInteger(position.index) || position.index < 0) throw new Error('帧下标必须是非负整数');
|
|
48
|
+
const compression = options.compression ?? 'none';
|
|
49
|
+
const chainHash = sha256(plain);
|
|
50
|
+
const payload = compression === 'zstd' ? zstdCompressSync(plain) : plain;
|
|
51
|
+
const compressedHash = compression === 'zstd' ? sha256(payload) : undefined;
|
|
52
|
+
const frameKey = frameKeyOf(sessionKey, chainHash, compression, compressedHash);
|
|
53
|
+
const keyId = keyIdOf(sessionKey);
|
|
54
|
+
const version = compression === 'zstd' ? SEALED_VERSION_ZSTD : SEALED_VERSION;
|
|
55
|
+
const header = Buffer.alloc(SEALED_HEADER_BYTES);
|
|
56
|
+
SEALED_MAGIC.copy(header, 0);
|
|
57
|
+
header.writeUInt8(version, 4);
|
|
58
|
+
header.writeUInt8(0, 5);
|
|
59
|
+
keyId.copy(header, 6);
|
|
60
|
+
FIXED_NONCE.copy(header, 14);
|
|
61
|
+
const aad = buildAad(header, {
|
|
62
|
+
...position,
|
|
63
|
+
chainHash
|
|
64
|
+
});
|
|
65
|
+
const cipher = createCipheriv('aes-256-gcm', frameKey, FIXED_NONCE);
|
|
66
|
+
cipher.setAAD(aad);
|
|
67
|
+
const body = Buffer.concat([
|
|
68
|
+
cipher.update(payload),
|
|
69
|
+
cipher.final()
|
|
70
|
+
]);
|
|
71
|
+
const bytes = Buffer.concat([
|
|
72
|
+
header,
|
|
73
|
+
body,
|
|
74
|
+
cipher.getAuthTag()
|
|
75
|
+
]);
|
|
76
|
+
return {
|
|
77
|
+
bytes,
|
|
78
|
+
ref: {
|
|
79
|
+
index: position.index,
|
|
80
|
+
chainHash,
|
|
81
|
+
blobSha256: sha256(bytes),
|
|
82
|
+
bytes: bytes.length,
|
|
83
|
+
sealedVersion: version,
|
|
84
|
+
keyId: keyId.toString('hex'),
|
|
85
|
+
seqFrom: position.seqFrom,
|
|
86
|
+
seqTo: position.seqTo,
|
|
87
|
+
...compression === 'zstd' ? {
|
|
88
|
+
compression: 'zstd',
|
|
89
|
+
compressedHash: compressedHash
|
|
90
|
+
} : {}
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
export function parseSealedHeader(sealed) {
|
|
95
|
+
if (sealed.length < SEALED_HEADER_BYTES + TAG_BYTES) throw new Error('封帧字节被截断');
|
|
96
|
+
if (!sealed.subarray(0, 4).equals(SEALED_MAGIC)) throw new Error('不是 DSHF 封帧');
|
|
97
|
+
const version = sealed.readUInt8(4);
|
|
98
|
+
if (version !== SEALED_VERSION && version !== SEALED_VERSION_ZSTD) {
|
|
99
|
+
throw new Error(`不支持的封帧版本:${version}`);
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
version,
|
|
103
|
+
flags: sealed.readUInt8(5),
|
|
104
|
+
keyId: sealed.subarray(6, 6 + KEY_ID_BYTES),
|
|
105
|
+
nonce: sealed.subarray(14, 14 + NONCE_BYTES)
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
export function openSealedFrame(sealed, position, sessionKey) {
|
|
109
|
+
const header = parseSealedHeader(sealed);
|
|
110
|
+
const expectedKeyId = keyIdOf(sessionKey);
|
|
111
|
+
if (!header.keyId.equals(expectedKeyId)) {
|
|
112
|
+
throw new Error('封帧的 keyId 与当前密钥不符(请确认各设备同步口令、服务端地址与 salt 一致)');
|
|
113
|
+
}
|
|
114
|
+
const compression = header.version === SEALED_VERSION_ZSTD ? 'zstd' : 'none';
|
|
115
|
+
if (compression === 'zstd') {
|
|
116
|
+
if (position.compression !== 'zstd' || position.compressedHash === undefined) {
|
|
117
|
+
throw new Error('zstd 封帧缺少压缩元数据');
|
|
118
|
+
}
|
|
119
|
+
} else if (position.compression !== undefined || position.compressedHash !== undefined) {
|
|
120
|
+
throw new Error('未压缩封帧不应带压缩元数据');
|
|
121
|
+
}
|
|
122
|
+
const headerBytes = sealed.subarray(0, SEALED_HEADER_BYTES);
|
|
123
|
+
const aad = buildAad(headerBytes, position);
|
|
124
|
+
const body = sealed.subarray(SEALED_HEADER_BYTES, sealed.length - TAG_BYTES);
|
|
125
|
+
const tag = sealed.subarray(sealed.length - TAG_BYTES);
|
|
126
|
+
const frameKey = frameKeyOf(sessionKey, position.chainHash, compression, position.compressedHash);
|
|
127
|
+
const decipher = createDecipheriv('aes-256-gcm', frameKey, header.nonce);
|
|
128
|
+
decipher.setAAD(aad);
|
|
129
|
+
decipher.setAuthTag(tag);
|
|
130
|
+
let payload;
|
|
131
|
+
try {
|
|
132
|
+
payload = Buffer.concat([
|
|
133
|
+
decipher.update(body),
|
|
134
|
+
decipher.final()
|
|
135
|
+
]);
|
|
136
|
+
} catch {
|
|
137
|
+
throw new Error('封帧认证失败(密文、AAD 或密钥不匹配)');
|
|
138
|
+
}
|
|
139
|
+
if (compression === 'zstd') {
|
|
140
|
+
if (sha256(payload) !== position.compressedHash) throw new Error('压缩明文哈希与清单不符');
|
|
141
|
+
let plain;
|
|
142
|
+
try {
|
|
143
|
+
plain = zstdDecompressSync(payload);
|
|
144
|
+
} catch {
|
|
145
|
+
throw new Error('zstd 解压失败');
|
|
146
|
+
}
|
|
147
|
+
if (sha256(plain) !== position.chainHash) throw new Error('解压后的明文哈希与清单不符');
|
|
148
|
+
return plain;
|
|
149
|
+
}
|
|
150
|
+
if (sha256(payload) !== position.chainHash) throw new Error('明文哈希与清单不符');
|
|
151
|
+
return payload;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
//# sourceURL=sealed-frame.ts
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHmac } from 'node:crypto';
|
|
2
|
+
import { stableStringify } from './chain.js';
|
|
3
|
+
import { deriveSubkey, KEY_BYTES, NONCE_BYTES, sha256, TAG_BYTES } from './crypto.js';
|
|
4
|
+
export const VAULT_SCHEMA = 'dsh-session-cloudsync/vault/1';
|
|
5
|
+
export const VAULT_FORMAT_VERSION = 1;
|
|
6
|
+
export const VAULT_KEY_ID_BYTES = 8;
|
|
7
|
+
export const WORKSPACE_KEY_CHARS = 24;
|
|
8
|
+
export const VAULT_GENESIS_HASH = null;
|
|
9
|
+
const FIXED_NONCE = Buffer.alloc(NONCE_BYTES);
|
|
10
|
+
const HASH_PATTERN = /^[0-9a-f]{64}$/u;
|
|
11
|
+
const KEY_ID_PATTERN = /^[0-9a-f]{16}$/u;
|
|
12
|
+
const KIND_PATTERN = /^[a-z][a-z0-9-]*\/[a-z][a-z0-9-]*$/u;
|
|
13
|
+
export function vaultKeyOf(master) {
|
|
14
|
+
assertKey(master, 'masterKey');
|
|
15
|
+
return deriveSubkey(master, 'dsh-sync/vault/v1');
|
|
16
|
+
}
|
|
17
|
+
export function vaultKeyIdOf(vaultKey) {
|
|
18
|
+
assertKey(vaultKey, 'vaultKey');
|
|
19
|
+
return deriveSubkey(vaultKey, 'v1|key-id').subarray(0, VAULT_KEY_ID_BYTES).toString('hex');
|
|
20
|
+
}
|
|
21
|
+
export function workspaceKeyOf(vaultKey, canonicalPath) {
|
|
22
|
+
assertKey(vaultKey, 'vaultKey');
|
|
23
|
+
if (canonicalPath.trim() === '') throw new Error('canonical workspace path 不能为空');
|
|
24
|
+
return createHmac('sha256', vaultKey).update(`workspace|${canonicalPath}`, 'utf8').digest('base64url').slice(0, WORKSPACE_KEY_CHARS);
|
|
25
|
+
}
|
|
26
|
+
export function sealVaultRecord(input) {
|
|
27
|
+
assertKey(input.vaultKey, 'vaultKey');
|
|
28
|
+
assertWriter(input.writer);
|
|
29
|
+
assertSeq(input.seq);
|
|
30
|
+
assertPrevHash(input.seq, input.prevHash);
|
|
31
|
+
assertTimestamp(input.createdAt, 'createdAt');
|
|
32
|
+
assertKind(input.kind);
|
|
33
|
+
assertEntity(input.entity);
|
|
34
|
+
assertTimestamp(input.at, 'at');
|
|
35
|
+
if (input.data === undefined) throw new Error('Vault data 不能是 undefined');
|
|
36
|
+
const payload = {
|
|
37
|
+
schema: VAULT_SCHEMA,
|
|
38
|
+
kind: input.kind,
|
|
39
|
+
entity: input.entity,
|
|
40
|
+
at: input.at,
|
|
41
|
+
data: input.data
|
|
42
|
+
};
|
|
43
|
+
const plain = Buffer.from(stableStringify(payload), 'utf8');
|
|
44
|
+
const keyId = vaultKeyIdOf(input.vaultKey);
|
|
45
|
+
const key = recordKeyOf(input.vaultKey, input.writer, input.seq);
|
|
46
|
+
const aad = aadOf(input.writer, input.seq, input.prevHash, VAULT_SCHEMA);
|
|
47
|
+
const cipher = createCipheriv('aes-256-gcm', key, FIXED_NONCE);
|
|
48
|
+
cipher.setAAD(aad);
|
|
49
|
+
const body = Buffer.concat([
|
|
50
|
+
cipher.update(plain),
|
|
51
|
+
cipher.final()
|
|
52
|
+
]);
|
|
53
|
+
const bytes = Buffer.concat([
|
|
54
|
+
body,
|
|
55
|
+
cipher.getAuthTag()
|
|
56
|
+
]);
|
|
57
|
+
const sealed = {
|
|
58
|
+
formatVersion: VAULT_FORMAT_VERSION,
|
|
59
|
+
keyId
|
|
60
|
+
};
|
|
61
|
+
const blobSha256 = sha256(bytes);
|
|
62
|
+
const ref = {
|
|
63
|
+
v: VAULT_FORMAT_VERSION,
|
|
64
|
+
writer: input.writer,
|
|
65
|
+
seq: input.seq,
|
|
66
|
+
prevHash: input.prevHash,
|
|
67
|
+
blobSha256,
|
|
68
|
+
bytes: bytes.length,
|
|
69
|
+
createdAt: input.createdAt,
|
|
70
|
+
sealed,
|
|
71
|
+
recordHash: vaultRecordHashOf({
|
|
72
|
+
v: VAULT_FORMAT_VERSION,
|
|
73
|
+
writer: input.writer,
|
|
74
|
+
seq: input.seq,
|
|
75
|
+
prevHash: input.prevHash,
|
|
76
|
+
blobSha256,
|
|
77
|
+
bytes: bytes.length,
|
|
78
|
+
createdAt: input.createdAt,
|
|
79
|
+
sealed
|
|
80
|
+
})
|
|
81
|
+
};
|
|
82
|
+
return {
|
|
83
|
+
ref,
|
|
84
|
+
bytes
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
export function openVaultRecord(record, vaultKey) {
|
|
88
|
+
assertKey(vaultKey, 'vaultKey');
|
|
89
|
+
assertVaultRecordRef(record.ref);
|
|
90
|
+
if (!Buffer.isBuffer(record.bytes)) throw new Error('Vault 密文必须是 Buffer');
|
|
91
|
+
if (record.bytes.length !== record.ref.bytes) throw new Error('Vault 密文字节数与索引不一致');
|
|
92
|
+
if (sha256(record.bytes) !== record.ref.blobSha256) throw new Error('Vault 密文哈希与索引不一致');
|
|
93
|
+
if (vaultKeyIdOf(vaultKey) !== record.ref.sealed.keyId) throw new Error('Vault keyId 不匹配');
|
|
94
|
+
const key = recordKeyOf(vaultKey, record.ref.writer, record.ref.seq);
|
|
95
|
+
if (record.bytes.length < TAG_BYTES) throw new Error('Vault 密文过短');
|
|
96
|
+
const body = record.bytes.subarray(0, record.bytes.length - TAG_BYTES);
|
|
97
|
+
const tag = record.bytes.subarray(record.bytes.length - TAG_BYTES);
|
|
98
|
+
const decipher = createDecipheriv('aes-256-gcm', key, FIXED_NONCE);
|
|
99
|
+
decipher.setAAD(aadOf(record.ref.writer, record.ref.seq, record.ref.prevHash, VAULT_SCHEMA));
|
|
100
|
+
decipher.setAuthTag(tag);
|
|
101
|
+
let parsed;
|
|
102
|
+
try {
|
|
103
|
+
const plain = Buffer.concat([
|
|
104
|
+
decipher.update(body),
|
|
105
|
+
decipher.final()
|
|
106
|
+
]);
|
|
107
|
+
parsed = JSON.parse(plain.toString('utf8'));
|
|
108
|
+
} catch (error) {
|
|
109
|
+
throw new Error(`Vault 记录解密失败:${error instanceof Error ? error.message : String(error)}`);
|
|
110
|
+
}
|
|
111
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('Vault 载荷不是对象');
|
|
112
|
+
const payload = parsed;
|
|
113
|
+
if (payload['schema'] !== VAULT_SCHEMA) throw new Error(`未知 Vault schema:${String(payload['schema'])}`);
|
|
114
|
+
if (typeof payload['kind'] !== 'string' || !KIND_PATTERN.test(payload['kind'])) throw new Error('Vault kind 非法');
|
|
115
|
+
if (typeof payload['entity'] !== 'string' || payload['entity'].trim() === '') throw new Error('Vault entity 非法');
|
|
116
|
+
if (!Number.isSafeInteger(payload['at'])) throw new Error('Vault at 非法');
|
|
117
|
+
if (payload['data'] === undefined) throw new Error('Vault data 缺失');
|
|
118
|
+
return {
|
|
119
|
+
schema: payload['schema'],
|
|
120
|
+
kind: payload['kind'],
|
|
121
|
+
entity: payload['entity'],
|
|
122
|
+
at: payload['at'],
|
|
123
|
+
data: payload['data']
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
export function assertVaultRecordRef(ref) {
|
|
127
|
+
if (ref.v !== VAULT_FORMAT_VERSION) throw new Error(`Vault 记录版本非法:${String(ref.v)}`);
|
|
128
|
+
assertWriter(ref.writer);
|
|
129
|
+
assertSeq(ref.seq);
|
|
130
|
+
assertPrevHash(ref.seq, ref.prevHash);
|
|
131
|
+
if (!HASH_PATTERN.test(ref.blobSha256)) throw new Error('Vault blobSha256 非法');
|
|
132
|
+
if (!Number.isSafeInteger(ref.bytes) || ref.bytes <= 0) throw new Error('Vault bytes 非法');
|
|
133
|
+
assertTimestamp(ref.createdAt, 'createdAt');
|
|
134
|
+
if (ref.sealed === null || typeof ref.sealed !== 'object') throw new Error('Vault sealed 非法');
|
|
135
|
+
if (ref.sealed.formatVersion !== VAULT_FORMAT_VERSION) throw new Error('Vault sealed.formatVersion 非法');
|
|
136
|
+
if (!KEY_ID_PATTERN.test(ref.sealed.keyId)) throw new Error('Vault sealed.keyId 非法');
|
|
137
|
+
if (!HASH_PATTERN.test(ref.recordHash)) throw new Error('Vault recordHash 非法');
|
|
138
|
+
const expected = vaultRecordHashOf(ref);
|
|
139
|
+
if (expected !== ref.recordHash) throw new Error('Vault recordHash 与索引内容不一致');
|
|
140
|
+
}
|
|
141
|
+
export function verifyVaultChain(records) {
|
|
142
|
+
let previous;
|
|
143
|
+
records.forEach((ref, index)=>{
|
|
144
|
+
const expectedSeq = index + 1;
|
|
145
|
+
if (ref.seq !== expectedSeq) throw new Error(`Vault seq 应为 ${expectedSeq},实际是 ${ref.seq}`);
|
|
146
|
+
assertVaultRecordRef(ref);
|
|
147
|
+
if (previous === undefined) {
|
|
148
|
+
if (ref.prevHash !== VAULT_GENESIS_HASH) throw new Error('Vault 链首条记录的 prevHash 必须为 null');
|
|
149
|
+
} else {
|
|
150
|
+
if (ref.writer !== previous.writer) throw new Error('Vault 链混用了不同 writer');
|
|
151
|
+
if (ref.prevHash !== previous.recordHash) throw new Error(`Vault 第 ${ref.seq} 条的 prevHash 不匹配`);
|
|
152
|
+
}
|
|
153
|
+
previous = ref;
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
function recordKeyOf(vaultKey, writer, seq) {
|
|
157
|
+
return deriveSubkey(vaultKey, `v1|vault-record:${writer}:${seq}`);
|
|
158
|
+
}
|
|
159
|
+
function aadOf(writer, seq, prevHash, schema) {
|
|
160
|
+
return Buffer.from(stableStringify({
|
|
161
|
+
v: VAULT_FORMAT_VERSION,
|
|
162
|
+
writer,
|
|
163
|
+
seq,
|
|
164
|
+
prevHash,
|
|
165
|
+
schema
|
|
166
|
+
}), 'utf8');
|
|
167
|
+
}
|
|
168
|
+
export function vaultRecordHashOf(ref) {
|
|
169
|
+
return sha256(stableStringify({
|
|
170
|
+
v: ref.v,
|
|
171
|
+
writer: ref.writer,
|
|
172
|
+
seq: ref.seq,
|
|
173
|
+
prevHash: ref.prevHash,
|
|
174
|
+
blobSha256: ref.blobSha256,
|
|
175
|
+
bytes: ref.bytes,
|
|
176
|
+
createdAt: ref.createdAt,
|
|
177
|
+
sealed: ref.sealed
|
|
178
|
+
}));
|
|
179
|
+
}
|
|
180
|
+
function assertKey(key, name) {
|
|
181
|
+
if (!Buffer.isBuffer(key) || key.length !== KEY_BYTES) throw new Error(`${name} 必须是 ${KEY_BYTES} 字节`);
|
|
182
|
+
}
|
|
183
|
+
function assertWriter(writer) {
|
|
184
|
+
if (typeof writer !== 'string' || writer.length === 0 || writer.length > 128) throw new Error('Vault writer 非法');
|
|
185
|
+
for (const character of writer){
|
|
186
|
+
if (character.charCodeAt(0) < 0x21 || character.charCodeAt(0) > 0x7e) throw new Error('Vault writer 含非法字符');
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
function assertSeq(seq) {
|
|
190
|
+
if (!Number.isSafeInteger(seq) || seq < 1) throw new Error('Vault seq 必须是正整数');
|
|
191
|
+
}
|
|
192
|
+
function assertPrevHash(seq, prevHash) {
|
|
193
|
+
if (seq === 1) {
|
|
194
|
+
if (prevHash !== VAULT_GENESIS_HASH) throw new Error('Vault seq=1 的 prevHash 必须为 null');
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (prevHash === null || !HASH_PATTERN.test(prevHash)) throw new Error('Vault prevHash 非法');
|
|
198
|
+
}
|
|
199
|
+
function assertTimestamp(value, name) {
|
|
200
|
+
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`Vault ${name} 非法`);
|
|
201
|
+
}
|
|
202
|
+
function assertKind(kind) {
|
|
203
|
+
if (typeof kind !== 'string' || !KIND_PATTERN.test(kind)) throw new Error('Vault kind 非法');
|
|
204
|
+
}
|
|
205
|
+
function assertEntity(entity) {
|
|
206
|
+
if (typeof entity !== 'string' || entity.trim() === '' || entity.length > 512) throw new Error('Vault entity 非法');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
//# sourceURL=vault.ts
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export const ERROR_STATUS = {
|
|
2
|
+
unauthorized: 401,
|
|
3
|
+
'bad-request': 400,
|
|
4
|
+
'not-found': 404,
|
|
5
|
+
conflict: 409,
|
|
6
|
+
diverged: 409,
|
|
7
|
+
'lease-held': 409,
|
|
8
|
+
gone: 410,
|
|
9
|
+
quota: 507,
|
|
10
|
+
'rate-limited': 429
|
|
11
|
+
};
|
|
12
|
+
export function statusOf(code) {
|
|
13
|
+
return ERROR_STATUS[code];
|
|
14
|
+
}
|
|
15
|
+
export class SyncError extends Error {
|
|
16
|
+
code;
|
|
17
|
+
constructor(code, message){
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = 'SyncError';
|
|
20
|
+
this.code = code;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
//# sourceURL=wire.ts
|
package/dist/vault.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { SyncError } from './shared/wire.js';
|
|
3
|
+
import { assertVaultRecordRef, vaultRecordHashOf } from './shared/vault.js';
|
|
4
|
+
import { blobPath } from './blobs.js';
|
|
5
|
+
import { DEFAULT_DATASET, bumpRevision, readRevision, withTransaction } from './db.js';
|
|
6
|
+
import { requireLease, vaultResource } from './leases.js';
|
|
7
|
+
export const MAX_VAULT_RECORD_BYTES = 1024 * 1024;
|
|
8
|
+
const HASH_PATTERN = /^[0-9a-f]{64}$/u;
|
|
9
|
+
const KEY_ID_PATTERN = /^[0-9a-f]{16}$/u;
|
|
10
|
+
function viewOf(row) {
|
|
11
|
+
let sealed;
|
|
12
|
+
try {
|
|
13
|
+
sealed = JSON.parse(row.sealed_json);
|
|
14
|
+
} catch {
|
|
15
|
+
throw new SyncError('bad-request', 'Vault sealed_json 损坏');
|
|
16
|
+
}
|
|
17
|
+
const ref = {
|
|
18
|
+
v: 1,
|
|
19
|
+
writer: row.writer,
|
|
20
|
+
seq: row.seq,
|
|
21
|
+
prevHash: row.prev_hash,
|
|
22
|
+
blobSha256: row.blob_sha256,
|
|
23
|
+
bytes: row.bytes,
|
|
24
|
+
createdAt: row.created_at,
|
|
25
|
+
sealed,
|
|
26
|
+
recordHash: row.record_hash
|
|
27
|
+
};
|
|
28
|
+
assertVaultRecordRef(ref);
|
|
29
|
+
return {
|
|
30
|
+
...ref,
|
|
31
|
+
revision: row.revision
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function lastRow(db, writer) {
|
|
35
|
+
return db.handle.prepare('SELECT writer, seq, prev_hash, blob_sha256, bytes, created_at, sealed_json, record_hash, revision FROM vault_records WHERE dataset = ? AND writer = ? ORDER BY seq DESC LIMIT 1').get(DEFAULT_DATASET, writer);
|
|
36
|
+
}
|
|
37
|
+
function rowAt(db, writer, seq) {
|
|
38
|
+
return db.handle.prepare('SELECT writer, seq, prev_hash, blob_sha256, bytes, created_at, sealed_json, record_hash, revision FROM vault_records WHERE dataset = ? AND writer = ? AND seq = ?').get(DEFAULT_DATASET, writer, seq);
|
|
39
|
+
}
|
|
40
|
+
function validateInput(input) {
|
|
41
|
+
if (input.writer !== input.deviceId) throw new SyncError('bad-request', 'writer 必须等于当前 deviceId');
|
|
42
|
+
if (!Number.isSafeInteger(input.seq) || input.seq < 1) throw new SyncError('bad-request', 'Vault seq 必须是正整数');
|
|
43
|
+
if (!Number.isSafeInteger(input.createdAt) || input.createdAt < 0) throw new SyncError('bad-request', 'Vault createdAt 非法');
|
|
44
|
+
if (!HASH_PATTERN.test(input.blobSha256)) throw new SyncError('bad-request', 'Vault blobSha256 非法');
|
|
45
|
+
if (!Number.isSafeInteger(input.bytes) || input.bytes <= 0 || input.bytes > MAX_VAULT_RECORD_BYTES) {
|
|
46
|
+
throw new SyncError('bad-request', 'Vault bytes 非法或超过 1 MiB');
|
|
47
|
+
}
|
|
48
|
+
if (input.sealed === null || typeof input.sealed !== 'object') throw new SyncError('bad-request', 'Vault sealed 非法');
|
|
49
|
+
if (input.sealed.formatVersion !== 1) throw new SyncError('bad-request', 'Vault sealed.formatVersion 必须是 1');
|
|
50
|
+
if (!KEY_ID_PATTERN.test(input.sealed.keyId)) throw new SyncError('bad-request', 'Vault sealed.keyId 非法');
|
|
51
|
+
if (input.seq === 1 && input.prevHash !== null) throw new SyncError('bad-request', 'Vault seq=1 的 prevHash 必须为 null');
|
|
52
|
+
if (input.seq > 1 && (input.prevHash === null || !HASH_PATTERN.test(input.prevHash))) {
|
|
53
|
+
throw new SyncError('bad-request', 'Vault prevHash 非法');
|
|
54
|
+
}
|
|
55
|
+
const fields = {
|
|
56
|
+
v: 1,
|
|
57
|
+
writer: input.writer,
|
|
58
|
+
seq: input.seq,
|
|
59
|
+
prevHash: input.prevHash,
|
|
60
|
+
blobSha256: input.blobSha256,
|
|
61
|
+
bytes: input.bytes,
|
|
62
|
+
createdAt: input.createdAt,
|
|
63
|
+
sealed: {
|
|
64
|
+
formatVersion: input.sealed.formatVersion,
|
|
65
|
+
keyId: input.sealed.keyId
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
const ref = {
|
|
69
|
+
...fields,
|
|
70
|
+
recordHash: vaultRecordHashOf(fields)
|
|
71
|
+
};
|
|
72
|
+
assertVaultRecordRef(ref);
|
|
73
|
+
return ref;
|
|
74
|
+
}
|
|
75
|
+
function sameRow(row, ref) {
|
|
76
|
+
return row.prev_hash === ref.prevHash && row.blob_sha256 === ref.blobSha256 && row.bytes === ref.bytes && row.created_at === ref.createdAt && row.record_hash === ref.recordHash && row.sealed_json === JSON.stringify(ref.sealed);
|
|
77
|
+
}
|
|
78
|
+
export function appendVaultRecord(db, dataDir, input, now = Date.now()) {
|
|
79
|
+
const ref = validateInput(input);
|
|
80
|
+
const existing = rowAt(db, input.writer, input.seq);
|
|
81
|
+
if (existing !== undefined) {
|
|
82
|
+
if (sameRow(existing, ref)) return {
|
|
83
|
+
record: viewOf(existing),
|
|
84
|
+
dedup: true
|
|
85
|
+
};
|
|
86
|
+
throw new SyncError('conflict', `Vault writer ${input.writer} 的 seq ${input.seq} 已有不同内容`);
|
|
87
|
+
}
|
|
88
|
+
return withTransaction(db, ()=>{
|
|
89
|
+
requireLease(db, vaultResource(input.writer), input.deviceId, input.clientId, now);
|
|
90
|
+
const raced = rowAt(db, input.writer, input.seq);
|
|
91
|
+
if (raced !== undefined) {
|
|
92
|
+
if (sameRow(raced, ref)) return {
|
|
93
|
+
record: viewOf(raced),
|
|
94
|
+
dedup: true
|
|
95
|
+
};
|
|
96
|
+
throw new SyncError('conflict', `Vault writer ${input.writer} 的 seq ${input.seq} 已有不同内容`);
|
|
97
|
+
}
|
|
98
|
+
const last = lastRow(db, input.writer);
|
|
99
|
+
if (last === undefined) {
|
|
100
|
+
if (ref.seq !== 1 || ref.prevHash !== null) throw new SyncError('conflict', 'Vault 首条必须从 seq=1 / prevHash=null 开始');
|
|
101
|
+
} else {
|
|
102
|
+
if (ref.seq !== last.seq + 1) throw new SyncError('conflict', `Vault 下一条 seq 应为 ${last.seq + 1}`);
|
|
103
|
+
if (ref.prevHash !== last.record_hash) throw new SyncError('conflict', 'Vault prevHash 与当前链不匹配');
|
|
104
|
+
}
|
|
105
|
+
let blob;
|
|
106
|
+
try {
|
|
107
|
+
blob = readBlobSync(dataDir, ref.blobSha256);
|
|
108
|
+
} catch {
|
|
109
|
+
throw new SyncError('bad-request', `Vault blob 尚未上传:${ref.blobSha256}`);
|
|
110
|
+
}
|
|
111
|
+
if (blob.length !== ref.bytes) throw new SyncError('bad-request', 'Vault blob 字节数与索引不一致');
|
|
112
|
+
const revision = bumpRevision(db);
|
|
113
|
+
db.handle.prepare('INSERT INTO vault_records (dataset, writer, seq, prev_hash, blob_sha256, bytes, created_at, sealed_json, record_hash, revision) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').run(DEFAULT_DATASET, ref.writer, ref.seq, ref.prevHash, ref.blobSha256, ref.bytes, ref.createdAt, JSON.stringify(ref.sealed), ref.recordHash, revision);
|
|
114
|
+
return {
|
|
115
|
+
record: {
|
|
116
|
+
...ref,
|
|
117
|
+
revision
|
|
118
|
+
},
|
|
119
|
+
dedup: false
|
|
120
|
+
};
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
function readBlobSync(dataDir, sha256) {
|
|
124
|
+
return readFileSync(blobPath(dataDir, sha256));
|
|
125
|
+
}
|
|
126
|
+
export function readVaultCursor(db, since, limit) {
|
|
127
|
+
const rows = db.handle.prepare('SELECT writer, seq, prev_hash, blob_sha256, bytes, created_at, sealed_json, record_hash, revision FROM vault_records WHERE dataset = ? AND revision > ? ORDER BY revision ASC LIMIT ?').all(DEFAULT_DATASET, since, limit + 1);
|
|
128
|
+
return {
|
|
129
|
+
revision: readRevision(db),
|
|
130
|
+
records: rows.slice(0, limit).map(viewOf),
|
|
131
|
+
hasMore: rows.length > limit
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
export function readVaultStream(db, writer, from, limit) {
|
|
135
|
+
const rows = db.handle.prepare('SELECT writer, seq, prev_hash, blob_sha256, bytes, created_at, sealed_json, record_hash, revision FROM vault_records WHERE dataset = ? AND writer = ? AND seq >= ? ORDER BY seq ASC LIMIT ?').all(DEFAULT_DATASET, writer, from, limit + 1);
|
|
136
|
+
return {
|
|
137
|
+
writer,
|
|
138
|
+
records: rows.slice(0, limit).map(viewOf),
|
|
139
|
+
hasMore: rows.length > limit
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
export function validVaultWriter(writer) {
|
|
143
|
+
return /^[A-Za-z0-9._:-]{1,128}$/u.test(writer);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
//# sourceURL=vault.ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,45 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dscs-server",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "DSH Session CloudSync self-hosted server.",
|
|
7
|
+
"license": "AGPL-3.0-only",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/xfy2412/dsh-session-cloudsync.git",
|
|
11
|
+
"directory": "packages/server"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/xfy2412/dsh-session-cloudsync#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/xfy2412/dsh-session-cloudsync/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"dsh",
|
|
19
|
+
"deepseek-harness",
|
|
20
|
+
"session",
|
|
21
|
+
"sync",
|
|
22
|
+
"self-hosted",
|
|
23
|
+
"e2ee",
|
|
24
|
+
"sqlite"
|
|
25
|
+
],
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"bin": {
|
|
30
|
+
"dscs-server": "dist/index.js"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist",
|
|
34
|
+
"web",
|
|
35
|
+
"LICENSE",
|
|
36
|
+
"README.md"
|
|
37
|
+
],
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "node scripts/build.mjs",
|
|
40
|
+
"prepack": "node scripts/build.mjs"
|
|
41
|
+
},
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=24"
|
|
44
|
+
}
|
|
45
|
+
}
|