dsh-webdav-sync 0.0.0-stage → 1.0.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.
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The sealed envelope the backup travels in: AES-256-GCM under a key derived
3
+ * from the passphrase with PBKDF2-SHA256. Nothing in it can be read without the
4
+ * passphrase — not by the WebDAV server, not by whoever else reaches the file.
5
+ *
6
+ * The envelope's own fields are authenticated as additional data, so the KDF
7
+ * parameters or salt cannot be swapped for weaker ones unnoticed.
8
+ */
9
+ /** Envelope format word. A file that does not carry it is not ours. */
10
+ export declare const FORMAT = "dsh-webdav-sync-backup";
11
+ /** PBKDF2 rounds. Matches the cost magpie settled on for the same job. */
12
+ export declare const ITERATIONS = 600000;
13
+ /** The wire envelope. Every field is authenticated by {@link header}. */
14
+ export interface Envelope {
15
+ format: string;
16
+ version: number;
17
+ kdf: string;
18
+ iterations: number;
19
+ salt: string;
20
+ nonce: string;
21
+ data: string;
22
+ }
23
+ /** The passphrase does not open this file — wrong passphrase, or the file changed. */
24
+ export declare class PassphraseError extends Error {
25
+ constructor();
26
+ }
27
+ /** The bytes are not a backup of ours, or are one cut short. */
28
+ export declare class CorruptError extends Error {
29
+ constructor(message?: string);
30
+ }
31
+ /** The file is a backup, but of a format this build does not read. */
32
+ export declare class NotBackupError extends Error {
33
+ constructor();
34
+ }
35
+ /** `true` when the body begins like a sealed backup, whatever it does after that. */
36
+ export declare function looksSealed(data: Uint8Array): boolean;
37
+ /**
38
+ * Seal a value into the envelope. The returned bytes are what leaves the
39
+ * computer; nothing else of the setup ever does.
40
+ */
41
+ export declare function seal(value: unknown, passphrase: string): Promise<Uint8Array>;
42
+ /**
43
+ * Open a sealed envelope. Throws {@link PassphraseError} when the passphrase is
44
+ * wrong and {@link CorruptError} / {@link NotBackupError} when the bytes are not
45
+ * a readable backup of ours.
46
+ */
47
+ export declare function open(data: Uint8Array, passphrase: string): Promise<unknown>;
48
+ /** Lowercase hex SHA-256, the plugin's one hash. */
49
+ export declare function sum(data: Uint8Array | string): string;
50
+ /** Lowercase hex SHA-256 of a value's canonical JSON, for whole-object hashing. */
51
+ export declare function hashJson(value: unknown): string;
52
+ /**
53
+ * JSON with object keys sorted, so the same value hashes the same however the
54
+ * YAML it came from happened to order its keys.
55
+ */
56
+ export declare function stableJson(value: unknown): string;
57
+ /** Constant-time string comparison, for anything secret-shaped. */
58
+ export declare function sameSecret(a: string, b: string): boolean;
59
+ /**
60
+ * The bytes without a byte-order mark. An editor on Windows may leave one at the
61
+ * head of a file it wrote, and a JSON parse fails on it for a reason that says
62
+ * nothing about what is actually wrong.
63
+ */
64
+ export declare function stripBom(data: Uint8Array): Uint8Array;
package/lib/crypto.js ADDED
@@ -0,0 +1,185 @@
1
+ /**
2
+ * The sealed envelope the backup travels in: AES-256-GCM under a key derived
3
+ * from the passphrase with PBKDF2-SHA256. Nothing in it can be read without the
4
+ * passphrase — not by the WebDAV server, not by whoever else reaches the file.
5
+ *
6
+ * The envelope's own fields are authenticated as additional data, so the KDF
7
+ * parameters or salt cannot be swapped for weaker ones unnoticed.
8
+ */
9
+ import { createCipheriv, createDecipheriv, pbkdf2, randomBytes, createHash, timingSafeEqual } from 'node:crypto';
10
+ import { promisify } from 'node:util';
11
+ const pbkdf2Async = promisify(pbkdf2);
12
+ /** Envelope format word. A file that does not carry it is not ours. */
13
+ export const FORMAT = 'dsh-webdav-sync-backup';
14
+ /** PBKDF2 rounds. Matches the cost magpie settled on for the same job. */
15
+ export const ITERATIONS = 600_000;
16
+ /** Rounds accepted when opening: a floor against a file that asks for a cheap KDF. */
17
+ const MIN_ITERATIONS = 100_000;
18
+ const MAX_ITERATIONS = 10_000_000;
19
+ const SALT_BYTES = 16;
20
+ const NONCE_BYTES = 12;
21
+ const KEY_BYTES = 32;
22
+ /** The passphrase does not open this file — wrong passphrase, or the file changed. */
23
+ export class PassphraseError extends Error {
24
+ constructor() {
25
+ super('口令打不开服务器上的文件:请用你在其他机器上设置的那个口令');
26
+ this.name = 'PassphraseError';
27
+ }
28
+ }
29
+ /** The bytes are not a backup of ours, or are one cut short. */
30
+ export class CorruptError extends Error {
31
+ constructor(message = '服务器上的文件不是一份完整的 dsh-webdav-sync 备份') {
32
+ super(message);
33
+ this.name = 'CorruptError';
34
+ }
35
+ }
36
+ /** The file is a backup, but of a format this build does not read. */
37
+ export class NotBackupError extends Error {
38
+ constructor() {
39
+ super('服务器上的文件不是 dsh-webdav-sync 写出来的');
40
+ this.name = 'NotBackupError';
41
+ }
42
+ }
43
+ /** `true` when the body begins like a sealed backup, whatever it does after that. */
44
+ export function looksSealed(data) {
45
+ const text = new TextDecoder().decode(data.subarray(0, 256)).replace(/\s+/g, '');
46
+ return text.startsWith('{"format":"' + FORMAT + '"');
47
+ }
48
+ /**
49
+ * Seal a value into the envelope. The returned bytes are what leaves the
50
+ * computer; nothing else of the setup ever does.
51
+ */
52
+ export async function seal(value, passphrase) {
53
+ // An empty passphrase would seal the profile with a key anyone can derive,
54
+ // which is worse than not syncing at all: refuse rather than pretend.
55
+ if (passphrase.trim() === '')
56
+ throw new Error('备份需要一个口令来加密');
57
+ const salt = randomBytes(SALT_BYTES);
58
+ const nonce = randomBytes(NONCE_BYTES);
59
+ const envelope = {
60
+ format: FORMAT,
61
+ version: 1,
62
+ kdf: 'pbkdf2-sha256',
63
+ iterations: ITERATIONS,
64
+ salt: salt.toString('hex'),
65
+ nonce: nonce.toString('hex'),
66
+ data: '',
67
+ };
68
+ const key = await derive(passphrase, salt, envelope.iterations);
69
+ const cipher = createCipheriv('aes-256-gcm', key, nonce);
70
+ cipher.setAAD(header(envelope));
71
+ const plain = Buffer.from(JSON.stringify(value), 'utf8');
72
+ const sealed = Buffer.concat([cipher.update(plain), cipher.final()]);
73
+ const tag = cipher.getAuthTag();
74
+ envelope.data = Buffer.concat([sealed, tag]).toString('base64');
75
+ // Indented so the head of the file reads as a backup at a glance, and a
76
+ // relay that cuts the body short still leaves a recognizable head behind.
77
+ return Buffer.from(JSON.stringify(envelope, null, 2), 'utf8');
78
+ }
79
+ /**
80
+ * Open a sealed envelope. Throws {@link PassphraseError} when the passphrase is
81
+ * wrong and {@link CorruptError} / {@link NotBackupError} when the bytes are not
82
+ * a readable backup of ours.
83
+ */
84
+ export async function open(data, passphrase) {
85
+ const text = new TextDecoder().decode(stripBom(data));
86
+ let envelope;
87
+ try {
88
+ envelope = JSON.parse(text);
89
+ }
90
+ catch {
91
+ // A backup a relay cut short ends in the middle of `data` and fails here, as
92
+ // does a sign-in page a captive portal answered with. Only a body that
93
+ // begins like ours counts as damage.
94
+ if (looksSealed(data))
95
+ throw new CorruptError();
96
+ throw new NotBackupError();
97
+ }
98
+ if (envelope.format !== FORMAT)
99
+ throw new NotBackupError();
100
+ if (envelope.version !== 1 || envelope.kdf !== 'pbkdf2-sha256') {
101
+ throw new NotBackupError();
102
+ }
103
+ const salt = Buffer.from(envelope.salt ?? '', 'hex');
104
+ const nonce = Buffer.from(envelope.nonce ?? '', 'hex');
105
+ if (!Number.isInteger(envelope.iterations) ||
106
+ envelope.iterations < MIN_ITERATIONS ||
107
+ envelope.iterations > MAX_ITERATIONS ||
108
+ nonce.length !== NONCE_BYTES ||
109
+ salt.length < SALT_BYTES) {
110
+ throw new CorruptError();
111
+ }
112
+ const body = Buffer.from(envelope.data ?? '', 'base64');
113
+ if (body.length <= 16)
114
+ throw new CorruptError();
115
+ const key = await derive(passphrase, salt, envelope.iterations);
116
+ const decipher = createDecipheriv('aes-256-gcm', key, nonce);
117
+ decipher.setAAD(header(envelope));
118
+ decipher.setAuthTag(body.subarray(body.length - 16));
119
+ let plain;
120
+ try {
121
+ plain = Buffer.concat([decipher.update(body.subarray(0, body.length - 16)), decipher.final()]);
122
+ }
123
+ catch {
124
+ // A tag that does not verify means the passphrase is wrong or the file was
125
+ // changed after it was written; the two are indistinguishable on purpose.
126
+ throw new PassphraseError();
127
+ }
128
+ try {
129
+ return JSON.parse(plain.toString('utf8'));
130
+ }
131
+ catch {
132
+ throw new CorruptError('服务器上的文件解开了,但里面不是可读的 dsh 配置');
133
+ }
134
+ }
135
+ /** What the sealing authenticates besides the bundle: the envelope's own fields. */
136
+ function header(envelope) {
137
+ return Buffer.from(`${envelope.format}/${envelope.version}/${envelope.kdf}/${envelope.iterations}/${envelope.salt}`, 'utf8');
138
+ }
139
+ async function derive(passphrase, salt, iterations) {
140
+ return (await pbkdf2Async(passphrase, salt, iterations, KEY_BYTES, 'sha256'));
141
+ }
142
+ /** Lowercase hex SHA-256, the plugin's one hash. */
143
+ export function sum(data) {
144
+ return createHash('sha256').update(data).digest('hex');
145
+ }
146
+ /** Lowercase hex SHA-256 of a value's canonical JSON, for whole-object hashing. */
147
+ export function hashJson(value) {
148
+ return sum(stableJson(value));
149
+ }
150
+ /**
151
+ * JSON with object keys sorted, so the same value hashes the same however the
152
+ * YAML it came from happened to order its keys.
153
+ */
154
+ export function stableJson(value) {
155
+ return JSON.stringify(sortValue(value));
156
+ }
157
+ function sortValue(value) {
158
+ if (Array.isArray(value))
159
+ return value.map(sortValue);
160
+ if (value === null || typeof value !== 'object')
161
+ return value;
162
+ const record = value;
163
+ const out = {};
164
+ for (const key of Object.keys(record).sort())
165
+ out[key] = sortValue(record[key]);
166
+ return out;
167
+ }
168
+ /** Constant-time string comparison, for anything secret-shaped. */
169
+ export function sameSecret(a, b) {
170
+ const left = Buffer.from(a, 'utf8');
171
+ const right = Buffer.from(b, 'utf8');
172
+ if (left.length !== right.length)
173
+ return false;
174
+ return timingSafeEqual(left, right);
175
+ }
176
+ /**
177
+ * The bytes without a byte-order mark. An editor on Windows may leave one at the
178
+ * head of a file it wrote, and a JSON parse fails on it for a reason that says
179
+ * nothing about what is actually wrong.
180
+ */
181
+ export function stripBom(data) {
182
+ if (data.length >= 3 && data[0] === 0xef && data[1] === 0xbb && data[2] === 0xbf)
183
+ return data.subarray(3);
184
+ return data;
185
+ }
package/lib/dav.d.ts ADDED
@@ -0,0 +1,137 @@
1
+ /**
2
+ * The WebDAV client. One file lives at `<address>/dsh-webdav/dsh-profile.dshsync`,
3
+ * and the whole protocol between this computer and that file is:
4
+ *
5
+ * - read it, with the version last seen so an unchanged file costs one request;
6
+ * - write it over the version that was read, so another computer's write in
7
+ * between is refused rather than silently overwritten.
8
+ *
9
+ * The servers that answer are as varied as the people who run them: 坚果云
10
+ * (Nutstore) counts requests per account and answers a read of a folder that is
11
+ * not there with 409 rather than 404; Synology answers 403 for a file in a
12
+ * missing folder; Apache's mod_deflate rewrites an ETag for the compressed copy
13
+ * it hands back, which then never matches in `If-Match`. Every one of those is
14
+ * handled where it happens, with the reason it exists written beside it.
15
+ */
16
+ import { type ServerVersion } from './wire.js';
17
+ /** The file was not written because it changed on the server since it was read. */
18
+ export declare class ChangedError extends Error {
19
+ constructor();
20
+ }
21
+ /** The file is still the version last read; the server did not send it again. */
22
+ export declare class NotModifiedError extends Error {
23
+ constructor();
24
+ }
25
+ /** The server refused the account. */
26
+ export declare class LoginError extends Error {
27
+ constructor();
28
+ }
29
+ /** The server is limiting how often it is reached, or is busy. */
30
+ export declare class RateLimitedError extends Error {
31
+ readonly status: number;
32
+ readonly after: number;
33
+ constructor(status: number, after: number);
34
+ }
35
+ /** A 403 the server would not explain; carries the folder the address names. */
36
+ export declare class ForbiddenError extends Error {
37
+ readonly explained: boolean;
38
+ constructor(message: string, explained: boolean);
39
+ }
40
+ /** What one address and account reach. */
41
+ export interface DavOptions {
42
+ url: string;
43
+ user: string;
44
+ password: string;
45
+ }
46
+ /**
47
+ * What one read found. `data` is absent when the server holds no file yet — which
48
+ * servers say in several ways, none of them an error. `notModified` is set when
49
+ * the server answered the conditional read with 304, in which case `data` is
50
+ * absent and `version` is the one that was asked about.
51
+ */
52
+ export interface DavRead {
53
+ data?: Uint8Array;
54
+ version: ServerVersion;
55
+ notModified?: boolean;
56
+ /** Where a redirect ended, when it ended somewhere other than the file. */
57
+ redirectedTo?: string;
58
+ }
59
+ /** A resolved WebDAV endpoint. */
60
+ export declare class Dav {
61
+ private readonly base;
62
+ /**
63
+ * The address's own path, without a trailing slash, and empty for a server
64
+ * root. It is kept aside because a URL cannot hold that emptiness: assigning
65
+ * `''` to a pathname puts the `/` straight back, and joining onto that `/`
66
+ * would build `//dsh-webdav`, which servers answer 400 to.
67
+ */
68
+ private readonly basePath;
69
+ private readonly user;
70
+ private readonly password;
71
+ constructor(options: DavOptions);
72
+ /** The address, without any credentials that may have ridden in on it. */
73
+ get address(): string;
74
+ /** The full URL of one path under the address. */
75
+ url(...parts: string[]): string;
76
+ /** Where the backup file itself lives. */
77
+ get fileUrl(): string;
78
+ /**
79
+ * Read the backup.
80
+ *
81
+ * Returns `undefined` when the server holds none yet — which several servers
82
+ * say in several ways. Throws {@link NotModifiedError} when `have` is still
83
+ * current, {@link LoginError}, {@link RateLimitedError} or {@link ForbiddenError}.
84
+ */
85
+ get(have?: ServerVersion): Promise<DavRead>;
86
+ /**
87
+ * Write the backup, only over the version that was read when there was one.
88
+ *
89
+ * Relays and tunnels cut long writes short, and the half that lands reads back
90
+ * as damage on every sync after, so what a write kept is read back and
91
+ * compared; a short write is tried again.
92
+ */
93
+ put(data: Uint8Array, etag?: string): Promise<ServerVersion>;
94
+ /** Whether the address is reachable and the account may read and write in it. */
95
+ check(): Promise<{
96
+ ok: true;
97
+ }>;
98
+ /** Read back what the file holds now, for the check action's report. */
99
+ stat(): Promise<{
100
+ exists: boolean;
101
+ size?: number;
102
+ }>;
103
+ /**
104
+ * Make the folder, tolerating the several ways servers say it is already
105
+ * there or that the folder above it is missing.
106
+ */
107
+ mkcol(): Promise<void>;
108
+ /**
109
+ * Say why a 403 was: servers answer it for a folder that is not there as much
110
+ * as for one the account may not write in, so the folder is looked at first.
111
+ */
112
+ private forbiddenMessage;
113
+ /**
114
+ * The folder a file could not be put in, named with the storages the address
115
+ * does hold — OpenList's and Alist's `/dav/` is a list of their storages, where
116
+ * MKCOL answers 405 and PUT 404, so the address has to go into one of them.
117
+ */
118
+ private nowhereMessage;
119
+ /** Whether PROPFIND finds a URL: a server answering 403 for what is not there may do so here too. */
120
+ private there;
121
+ /**
122
+ * The size a write kept, read back. `short` is true only when a real size was
123
+ * found and it is less than what was sent: a HEAD that cannot be done leaves
124
+ * the write unchecked, because it landed either way.
125
+ */
126
+ private verifyLength;
127
+ /** The file's size as PROPFIND (Depth 0) reports it; `undefined` when it says none. */
128
+ private length;
129
+ /** The child collections of a URL, as PROPFIND (Depth 1) lists them, and the status. */
130
+ private list;
131
+ /** One PROPFIND, with the Depth header the caller asks for. */
132
+ private propfind;
133
+ /** One request, with auth, redirect and rate-limit handling applied. */
134
+ private send;
135
+ /** The rate-limit error for an answer, carrying the server's own wait when it named one. */
136
+ private limited;
137
+ }