@weibaohui/dsh-git-server 0.1.0 → 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/README.md +13 -11
- package/client/bundle.js +55 -4
- package/client/index.js +55 -4
- package/package.json +1 -1
- package/server/dist/serv.js +0 -43
- package/server/dist/sshx/dispatch.js +0 -160
- package/server/dist/sshx/lfs-transfer.js +0 -289
- package/server/dist/sshx/server.js +0 -107
- package/server/dist/twofactor.js +0 -160
- package/server/dist/webapi.js +0 -377
|
@@ -1,289 +0,0 @@
|
|
|
1
|
-
// git-lfs-transfer: pure SSH LFS transfer protocol (lfs-transfer-1) per the
|
|
2
|
-
// git-lfs spec (docs/proposals/ssh_adapter.md). pkt-line framing: flush=0000,
|
|
3
|
-
// delim=0001, data pkt = hex(len) + payload. Binary payloads are pkt-framed.
|
|
4
|
-
import * as crypto from 'node:crypto';
|
|
5
|
-
import * as fs from 'node:fs';
|
|
6
|
-
import { conf } from '../conf.js';
|
|
7
|
-
import * as db from '../db/db.js';
|
|
8
|
-
// pkt-line control frames are FOUR ASCII chars '0000'/'0001'
|
|
9
|
-
const FLUSH = Buffer.from('0000', 'ascii');
|
|
10
|
-
const DELIM = Buffer.from('0001', 'ascii');
|
|
11
|
-
function pkt(data) {
|
|
12
|
-
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
|
|
13
|
-
const header = Buffer.from((buf.length + 4).toString(16).padStart(4, '0'), 'ascii');
|
|
14
|
-
return Buffer.concat([header, buf]);
|
|
15
|
-
}
|
|
16
|
-
function lfsObjectPath(oid) {
|
|
17
|
-
return `${conf.appDataPath}/lfs-objects/${oid.slice(0, 2)}/${oid.slice(2, 4)}/${oid}`;
|
|
18
|
-
}
|
|
19
|
-
/** Resolve owner/repo the same way as dispatch.serveGit. */
|
|
20
|
-
function resolveRepo(repoFullName) {
|
|
21
|
-
let repoName = repoFullName.replace(/^\//, '').replace(/\.git$/, '');
|
|
22
|
-
if (repoName.endsWith('.wiki'))
|
|
23
|
-
repoName = repoName.slice(0, -5);
|
|
24
|
-
const slash = repoName.indexOf('/');
|
|
25
|
-
const ownerName = slash > 0 ? repoName.slice(0, slash) : repoName;
|
|
26
|
-
const name = slash > 0 ? repoName.slice(slash + 1) : repoName;
|
|
27
|
-
const owner = db.getUserByUsername(ownerName);
|
|
28
|
-
const repo = owner ? db.getRepoByOwnerAndName(owner, name) : null;
|
|
29
|
-
return owner && repo ? { owner, repo } : null;
|
|
30
|
-
}
|
|
31
|
-
class PktReader {
|
|
32
|
-
buf = Buffer.alloc(0);
|
|
33
|
-
closed = false;
|
|
34
|
-
waiters = [];
|
|
35
|
-
push(data) {
|
|
36
|
-
this.buf = Buffer.concat([this.buf, data]);
|
|
37
|
-
const w = this.waiters.splice(0);
|
|
38
|
-
for (const fn of w)
|
|
39
|
-
fn();
|
|
40
|
-
}
|
|
41
|
-
end() {
|
|
42
|
-
this.closed = true;
|
|
43
|
-
const w = this.waiters.splice(0);
|
|
44
|
-
for (const fn of w)
|
|
45
|
-
fn();
|
|
46
|
-
}
|
|
47
|
-
/** Returns next pkt: {type:'data',payload} | {type:'flush'} | {type:'delim'} | null(EOF). */
|
|
48
|
-
async next() {
|
|
49
|
-
while (true) {
|
|
50
|
-
if (this.buf.length >= 4) {
|
|
51
|
-
const lenStr = this.buf.toString('utf8', 0, 4);
|
|
52
|
-
if (/^[0-9a-f]{4}$/.test(lenStr)) {
|
|
53
|
-
const len = parseInt(lenStr, 16);
|
|
54
|
-
if (len === 0) {
|
|
55
|
-
this.buf = this.buf.subarray(4);
|
|
56
|
-
return { type: 'flush', payload: Buffer.alloc(0) };
|
|
57
|
-
}
|
|
58
|
-
if (len === 1) {
|
|
59
|
-
this.buf = this.buf.subarray(4);
|
|
60
|
-
return { type: 'delim', payload: Buffer.alloc(0) };
|
|
61
|
-
}
|
|
62
|
-
if (len >= 4 && this.buf.length >= len) {
|
|
63
|
-
const payload = this.buf.subarray(4, len);
|
|
64
|
-
this.buf = this.buf.subarray(len);
|
|
65
|
-
return { type: 'data', payload };
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
if (this.closed && this.buf.length < 4)
|
|
70
|
-
return null;
|
|
71
|
-
await new Promise((resolve) => this.waiters.push(resolve));
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
export async function runLFSTransfer(argStr, identity, io) {
|
|
76
|
-
const parts = argStr.trim().split(/\s+/);
|
|
77
|
-
const repoFullName = parts[0];
|
|
78
|
-
const operation = parts[1] ?? 'download';
|
|
79
|
-
if (!['download', 'upload'].includes(operation)) {
|
|
80
|
-
io.write(Buffer.from(`git-lfs-transfer: invalid operation: ${operation}\n`));
|
|
81
|
-
io.close(1);
|
|
82
|
-
return;
|
|
83
|
-
}
|
|
84
|
-
const resolved = resolveRepo(repoFullName);
|
|
85
|
-
if (!resolved) {
|
|
86
|
-
io.write(Buffer.from('Repository does not exist\n'));
|
|
87
|
-
io.close(1);
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
90
|
-
const { owner, repo } = resolved;
|
|
91
|
-
const mode = db.accessMode(identity.userID, repo);
|
|
92
|
-
const need = operation === 'upload' ? db.AccessMode.WRITE : db.AccessMode.READ;
|
|
93
|
-
if (!(operation === 'download' && !repo.is_private && !conf.requireSigninView) && mode < need) {
|
|
94
|
-
io.write(Buffer.from('Access denied\n'));
|
|
95
|
-
io.close(1);
|
|
96
|
-
return;
|
|
97
|
-
}
|
|
98
|
-
const reader = new PktReader();
|
|
99
|
-
io.onStdin((data) => reader.push(data));
|
|
100
|
-
const writePkt = (s) => io.write(pkt(s));
|
|
101
|
-
const writeFlush = () => io.write(FLUSH);
|
|
102
|
-
const writeDelim = () => io.write(DELIM);
|
|
103
|
-
const writeStatus = (code, args = [], delim = false) => {
|
|
104
|
-
io.write(pkt(`status ${code}`));
|
|
105
|
-
for (const a of args)
|
|
106
|
-
io.write(pkt(a));
|
|
107
|
-
if (delim)
|
|
108
|
-
writeDelim();
|
|
109
|
-
writeFlush();
|
|
110
|
-
};
|
|
111
|
-
// 1. capability advertisement (server speaks first)
|
|
112
|
-
io.write(pkt('capability-list'));
|
|
113
|
-
io.write(pkt('version=1'));
|
|
114
|
-
io.write(pkt('begin-upload'));
|
|
115
|
-
io.write(pkt('begin-download'));
|
|
116
|
-
writeFlush();
|
|
117
|
-
let quit = false;
|
|
118
|
-
while (!quit) {
|
|
119
|
-
const p = await reader.next();
|
|
120
|
-
if (p === null)
|
|
121
|
-
break;
|
|
122
|
-
if (p.type === 'flush')
|
|
123
|
-
continue;
|
|
124
|
-
if (p.type !== 'data')
|
|
125
|
-
continue;
|
|
126
|
-
const line = p.payload.toString('utf8').replace(/\n$/, '');
|
|
127
|
-
const sp = line.indexOf(' ');
|
|
128
|
-
const cmd = sp < 0 ? line : line.slice(0, sp);
|
|
129
|
-
const rest = sp < 0 ? '' : line.slice(sp + 1);
|
|
130
|
-
switch (cmd) {
|
|
131
|
-
case 'version': {
|
|
132
|
-
if (rest === '1')
|
|
133
|
-
writeStatus(200);
|
|
134
|
-
else
|
|
135
|
-
writeStatus(400);
|
|
136
|
-
break;
|
|
137
|
-
}
|
|
138
|
-
case 'quit': {
|
|
139
|
-
writeStatus(200);
|
|
140
|
-
quit = true;
|
|
141
|
-
break;
|
|
142
|
-
}
|
|
143
|
-
case 'batch': {
|
|
144
|
-
// collect arguments until delim, then oid lines until flush
|
|
145
|
-
const args = {};
|
|
146
|
-
const oids = [];
|
|
147
|
-
let inOids = false;
|
|
148
|
-
while (true) {
|
|
149
|
-
const q = await reader.next();
|
|
150
|
-
if (q === null || q.type === 'flush')
|
|
151
|
-
break;
|
|
152
|
-
if (q.type === 'delim') {
|
|
153
|
-
inOids = true;
|
|
154
|
-
continue;
|
|
155
|
-
}
|
|
156
|
-
if (q.type !== 'data')
|
|
157
|
-
continue;
|
|
158
|
-
const l = q.payload.toString('utf8').replace(/\n$/, '');
|
|
159
|
-
if (!inOids) {
|
|
160
|
-
const eq = l.indexOf('=');
|
|
161
|
-
if (eq > 0)
|
|
162
|
-
args[l.slice(0, eq)] = l.slice(eq + 1);
|
|
163
|
-
}
|
|
164
|
-
else {
|
|
165
|
-
const m = /^(\S+) (\d+)(?: (.*))?$/.exec(l);
|
|
166
|
-
if (m)
|
|
167
|
-
oids.push({ oid: m[1], size: Number(m[2]) });
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
io.write(pkt('status 200'));
|
|
171
|
-
writeDelim();
|
|
172
|
-
const wantUpload = operation === 'upload';
|
|
173
|
-
for (const o of oids) {
|
|
174
|
-
const row = db.db().prepare('SELECT * FROM lfs_object WHERE repo_id = ? AND oid = ?').get(repo.id, o.oid);
|
|
175
|
-
const exists = !!row && fs.existsSync(lfsObjectPath(o.oid));
|
|
176
|
-
let action;
|
|
177
|
-
if (wantUpload) {
|
|
178
|
-
// upload: offer upload when missing or size mismatch; else noop
|
|
179
|
-
action = !exists || row.size !== o.size ? 'upload' : 'noop';
|
|
180
|
-
}
|
|
181
|
-
else {
|
|
182
|
-
if (!exists) {
|
|
183
|
-
action = 'noop'; // mirrors HTTP 404 → error shape
|
|
184
|
-
io.write(pkt(`${o.oid} ${o.size} error message=[Object does not exist]`));
|
|
185
|
-
continue;
|
|
186
|
-
}
|
|
187
|
-
action = row.size === o.size ? 'download' : 'noop';
|
|
188
|
-
}
|
|
189
|
-
io.write(pkt(`${o.oid} ${o.size} ${action}`));
|
|
190
|
-
}
|
|
191
|
-
writeFlush();
|
|
192
|
-
break;
|
|
193
|
-
}
|
|
194
|
-
case 'get-object': {
|
|
195
|
-
const oid = rest.trim();
|
|
196
|
-
const file = lfsObjectPath(oid);
|
|
197
|
-
const row = db.db().prepare('SELECT * FROM lfs_object WHERE repo_id = ? AND oid = ?').get(repo.id, oid);
|
|
198
|
-
if (!row || !fs.existsSync(file)) {
|
|
199
|
-
writeStatus(404, [], true);
|
|
200
|
-
io.write(pkt('Object does not exist\n'));
|
|
201
|
-
writeFlush();
|
|
202
|
-
break;
|
|
203
|
-
}
|
|
204
|
-
io.write(pkt('status 200'));
|
|
205
|
-
io.write(pkt(`size=${row.size}`));
|
|
206
|
-
writeDelim();
|
|
207
|
-
const content = fs.readFileSync(file);
|
|
208
|
-
for (let off = 0; off < content.length; off += 65516) {
|
|
209
|
-
io.write(pkt(content.subarray(off, Math.min(off + 65516, content.length))));
|
|
210
|
-
}
|
|
211
|
-
writeFlush();
|
|
212
|
-
break;
|
|
213
|
-
}
|
|
214
|
-
case 'put-object': {
|
|
215
|
-
const oid = rest.trim();
|
|
216
|
-
// arguments until DELIM, then binary data until FLUSH
|
|
217
|
-
let size = 0;
|
|
218
|
-
while (true) {
|
|
219
|
-
const q = await reader.next();
|
|
220
|
-
if (q === null || q.type === 'flush')
|
|
221
|
-
break;
|
|
222
|
-
if (q.type === 'delim')
|
|
223
|
-
break;
|
|
224
|
-
if (q.type !== 'data')
|
|
225
|
-
continue;
|
|
226
|
-
const l = q.payload.toString('utf8').replace(/\n$/, '');
|
|
227
|
-
const eq = l.indexOf('=');
|
|
228
|
-
if (eq > 0 && l.slice(0, eq) === 'size')
|
|
229
|
-
size = Number(l.slice(eq + 1));
|
|
230
|
-
}
|
|
231
|
-
// binary payload: collect until flush
|
|
232
|
-
const chunks = [];
|
|
233
|
-
while (true) {
|
|
234
|
-
const q = await reader.next();
|
|
235
|
-
if (q === null || q.type === 'flush')
|
|
236
|
-
break;
|
|
237
|
-
if (q.type === 'data')
|
|
238
|
-
chunks.push(q.payload);
|
|
239
|
-
}
|
|
240
|
-
const content = Buffer.concat(chunks);
|
|
241
|
-
const hash = crypto.createHash('sha256').update(content).digest('hex');
|
|
242
|
-
if (hash !== oid || (size > 0 && content.length !== size)) {
|
|
243
|
-
writeStatus(422, [], true);
|
|
244
|
-
io.write(pkt('Content-Length and Oid mismatches\n'));
|
|
245
|
-
writeFlush();
|
|
246
|
-
break;
|
|
247
|
-
}
|
|
248
|
-
const dst = lfsObjectPath(oid);
|
|
249
|
-
fs.mkdirSync(dst.slice(0, dst.lastIndexOf('/')), { recursive: true });
|
|
250
|
-
fs.writeFileSync(dst, content);
|
|
251
|
-
db.db()
|
|
252
|
-
.prepare('INSERT OR REPLACE INTO lfs_object (repo_id, oid, size, storage, created_at) VALUES (?,?,?,?,?)')
|
|
253
|
-
.run(repo.id, oid, content.length, 'local', new Date().toISOString().replace('T', ' ').replace('Z', ''));
|
|
254
|
-
writeStatus(200);
|
|
255
|
-
break;
|
|
256
|
-
}
|
|
257
|
-
case 'verify-object': {
|
|
258
|
-
const oid = rest.trim();
|
|
259
|
-
let size = 0;
|
|
260
|
-
while (true) {
|
|
261
|
-
const q = await reader.next();
|
|
262
|
-
if (q === null || q.type === 'flush')
|
|
263
|
-
break;
|
|
264
|
-
if (q.type !== 'data')
|
|
265
|
-
continue;
|
|
266
|
-
const l = q.payload.toString('utf8').replace(/\n$/, '');
|
|
267
|
-
const eq = l.indexOf('=');
|
|
268
|
-
if (eq > 0 && l.slice(0, eq) === 'size')
|
|
269
|
-
size = Number(l.slice(eq + 1));
|
|
270
|
-
}
|
|
271
|
-
const row = db.db().prepare('SELECT * FROM lfs_object WHERE repo_id = ? AND oid = ?').get(repo.id, oid);
|
|
272
|
-
if (!row) {
|
|
273
|
-
writeStatus(404);
|
|
274
|
-
break;
|
|
275
|
-
}
|
|
276
|
-
writeStatus(row.size === size ? 200 : 422);
|
|
277
|
-
break;
|
|
278
|
-
}
|
|
279
|
-
default: {
|
|
280
|
-
// unknown command → protocol error status
|
|
281
|
-
writeStatus(400, [], true);
|
|
282
|
-
io.write(pkt(`Unknown command: ${cmd}\n`));
|
|
283
|
-
writeFlush();
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
io.close(0);
|
|
288
|
-
}
|
|
289
|
-
//# sourceMappingURL=lfs-transfer.js.map
|
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
// Builtin SSH server (gogs START_SSH_SERVER): publickey auth against the
|
|
2
|
-
// public_key table, then pipes `git-upload-pack`/`git-receive-pack` sessions.
|
|
3
|
-
import * as fs from 'node:fs';
|
|
4
|
-
import * as path from 'node:path';
|
|
5
|
-
import * as crypto from 'node:crypto';
|
|
6
|
-
import { conf } from '../conf.js';
|
|
7
|
-
import * as db from '../db/db.js';
|
|
8
|
-
import { dispatchSSHCommand } from './dispatch.js';
|
|
9
|
-
function hostKeyPath() {
|
|
10
|
-
return path.join(conf.customDir, 'ssh', 'gogs_ed25519.key');
|
|
11
|
-
}
|
|
12
|
-
function ensureHostKey() {
|
|
13
|
-
const file = hostKeyPath();
|
|
14
|
-
if (fs.existsSync(file))
|
|
15
|
-
return fs.readFileSync(file, 'utf8');
|
|
16
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
17
|
-
const { privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
|
|
18
|
-
// ssh2 parses PKCS#1 "RSA PRIVATE KEY" PEM reliably
|
|
19
|
-
const pem = privateKey.export({ type: 'pkcs1', format: 'pem' }).toString();
|
|
20
|
-
fs.writeFileSync(file, pem, { mode: 0o600 });
|
|
21
|
-
return pem;
|
|
22
|
-
}
|
|
23
|
-
/** Normalize a public key to "type base64" for comparison. */
|
|
24
|
-
function normalizeKey(content) {
|
|
25
|
-
const parts = content.trim().split(/\s+/);
|
|
26
|
-
if (parts.length < 2)
|
|
27
|
-
return '';
|
|
28
|
-
return `${parts[0]} ${parts[1]}`;
|
|
29
|
-
}
|
|
30
|
-
async function authorizeKey(keyData) {
|
|
31
|
-
const presented = normalizeKey(keyData.toString('utf8'));
|
|
32
|
-
if (!presented)
|
|
33
|
-
return null;
|
|
34
|
-
const rows = db.db().prepare('SELECT id, owner_id, content, type FROM public_key').all();
|
|
35
|
-
for (const row of rows) {
|
|
36
|
-
if (normalizeKey(row.content) === presented) {
|
|
37
|
-
if (row.type === 2) {
|
|
38
|
-
const dk = db.db().prepare('SELECT repo_id FROM deploy_key WHERE key_id = ?').get(row.id);
|
|
39
|
-
return { userID: row.owner_id, keyID: row.id, deployRepoID: dk?.repo_id ?? null };
|
|
40
|
-
}
|
|
41
|
-
return { userID: row.owner_id, keyID: row.id, deployRepoID: null };
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
return null;
|
|
45
|
-
}
|
|
46
|
-
export function startSSHServer() {
|
|
47
|
-
import('ssh2').then(({ default: ssh2 }) => {
|
|
48
|
-
const hostKey = ensureHostKey();
|
|
49
|
-
const server = new ssh2.Server({ hostKeys: [hostKey] }, (client) => {
|
|
50
|
-
let authedUser = null;
|
|
51
|
-
client
|
|
52
|
-
.on('ready', () => {
|
|
53
|
-
client.on('session', (accept) => {
|
|
54
|
-
const session = accept();
|
|
55
|
-
session.on('exec', (accept, _reject, info) => {
|
|
56
|
-
const stream = accept();
|
|
57
|
-
const user = authedUser;
|
|
58
|
-
if (!user) {
|
|
59
|
-
stream.exit(1);
|
|
60
|
-
stream.end();
|
|
61
|
-
return;
|
|
62
|
-
}
|
|
63
|
-
dispatchSSHCommand(String(info.command), user, {
|
|
64
|
-
write: (chunk) => stream.write(chunk),
|
|
65
|
-
onStdin: (cb) => stream.on('data', (d) => cb(d)),
|
|
66
|
-
close: (code) => {
|
|
67
|
-
stream.exit(code);
|
|
68
|
-
stream.end();
|
|
69
|
-
},
|
|
70
|
-
});
|
|
71
|
-
});
|
|
72
|
-
});
|
|
73
|
-
})
|
|
74
|
-
.on('error', () => { });
|
|
75
|
-
client.on('authentication', (ctx) => {
|
|
76
|
-
if (ctx.method !== 'publickey') {
|
|
77
|
-
return ctx.reject(['publickey']);
|
|
78
|
-
}
|
|
79
|
-
const buf = ctx.key?.data;
|
|
80
|
-
if (!buf)
|
|
81
|
-
return ctx.reject();
|
|
82
|
-
// the wire blob starts with a length-prefixed algorithm name
|
|
83
|
-
const algLen = buf.readUInt32BE(0);
|
|
84
|
-
const algName = buf.subarray(4, 4 + algLen).toString('utf8');
|
|
85
|
-
// the .pub base64 IS the full wire blob (alg prefix + body)
|
|
86
|
-
const b64 = buf.toString('base64');
|
|
87
|
-
const line = `${algName} ${b64}`;
|
|
88
|
-
authorizeKey(Buffer.from(line)).then((u) => {
|
|
89
|
-
if (u) {
|
|
90
|
-
authedUser = u;
|
|
91
|
-
ctx.accept();
|
|
92
|
-
}
|
|
93
|
-
else {
|
|
94
|
-
ctx.reject();
|
|
95
|
-
}
|
|
96
|
-
});
|
|
97
|
-
});
|
|
98
|
-
});
|
|
99
|
-
const port = conf.sshPort === 22 ? 22 : conf.sshPort;
|
|
100
|
-
const listenPort = process.env.GOGS_SSH_PORT ? Number(process.env.GOGS_SSH_PORT) : port;
|
|
101
|
-
server.listen(listenPort, '0.0.0.0', () => {
|
|
102
|
-
console.log(`SSH server started on 0.0.0.0:${listenPort}`);
|
|
103
|
-
});
|
|
104
|
-
server.on('error', (e) => console.error('[ssh]', e));
|
|
105
|
-
});
|
|
106
|
-
}
|
|
107
|
-
//# sourceMappingURL=server.js.map
|
package/server/dist/twofactor.js
DELETED
|
@@ -1,160 +0,0 @@
|
|
|
1
|
-
// Two-factor authentication: TOTP (RFC 6238), AES-GCM secret storage compatible
|
|
2
|
-
// with gogs cryptox, and recovery codes.
|
|
3
|
-
import * as crypto from 'node:crypto';
|
|
4
|
-
import * as db from './db/db.js';
|
|
5
|
-
import { conf } from './conf.js';
|
|
6
|
-
import { randomChars } from './db/db.js';
|
|
7
|
-
// ---------------------------------------------------------------- crypto (cryptox compat)
|
|
8
|
-
function md5Bytes(str) {
|
|
9
|
-
return crypto.createHash('md5').update(str).digest();
|
|
10
|
-
}
|
|
11
|
-
export function aesGcmEncrypt(key, plaintext) {
|
|
12
|
-
// golang.org/x/crypto GCM: 12-byte nonce prepended, tag appended
|
|
13
|
-
const nonce = crypto.randomBytes(12);
|
|
14
|
-
const cipher = crypto.createCipheriv('aes-128-gcm', key, nonce);
|
|
15
|
-
const enc = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
16
|
-
return Buffer.concat([nonce, enc, cipher.getAuthTag()]);
|
|
17
|
-
}
|
|
18
|
-
export function aesGcmDecrypt(key, ciphertext) {
|
|
19
|
-
const nonce = ciphertext.subarray(0, 12);
|
|
20
|
-
const tag = ciphertext.subarray(ciphertext.length - 16);
|
|
21
|
-
const data = ciphertext.subarray(12, ciphertext.length - 16);
|
|
22
|
-
const decipher = crypto.createDecipheriv('aes-128-gcm', key, nonce);
|
|
23
|
-
decipher.setAuthTag(tag);
|
|
24
|
-
return Buffer.concat([decipher.update(data), decipher.final()]);
|
|
25
|
-
}
|
|
26
|
-
function secretKey() {
|
|
27
|
-
return md5Bytes(conf.secretKey);
|
|
28
|
-
}
|
|
29
|
-
// ---------------------------------------------------------------- TOTP (RFC 6238)
|
|
30
|
-
const B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
31
|
-
function base32Encode(buf) {
|
|
32
|
-
let bits = 0;
|
|
33
|
-
let value = 0;
|
|
34
|
-
let out = '';
|
|
35
|
-
for (const byte of buf) {
|
|
36
|
-
value = (value << 8) | byte;
|
|
37
|
-
bits += 8;
|
|
38
|
-
while (bits >= 5) {
|
|
39
|
-
out += B32[(value >>> (bits - 5)) & 31];
|
|
40
|
-
bits -= 5;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
if (bits > 0)
|
|
44
|
-
out += B32[(value << (5 - bits)) & 31];
|
|
45
|
-
return out;
|
|
46
|
-
}
|
|
47
|
-
function base32Decode(str) {
|
|
48
|
-
const clean = str.toUpperCase().replace(/[=\s]/g, '');
|
|
49
|
-
let bits = 0;
|
|
50
|
-
let value = 0;
|
|
51
|
-
const out = [];
|
|
52
|
-
for (const ch of clean) {
|
|
53
|
-
const idx = B32.indexOf(ch);
|
|
54
|
-
if (idx < 0)
|
|
55
|
-
continue;
|
|
56
|
-
value = (value << 5) | idx;
|
|
57
|
-
bits += 5;
|
|
58
|
-
if (bits >= 8) {
|
|
59
|
-
out.push((value >>> (bits - 8)) & 0xff);
|
|
60
|
-
bits -= 8;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
return Buffer.from(out);
|
|
64
|
-
}
|
|
65
|
-
export function totpGenerate(issuer, accountName) {
|
|
66
|
-
const raw = crypto.randomBytes(20);
|
|
67
|
-
const secret = base32Encode(raw);
|
|
68
|
-
const url = `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(accountName)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}`;
|
|
69
|
-
return { secret, url };
|
|
70
|
-
}
|
|
71
|
-
export function totpCode(secret, timeStep = Math.floor(Date.now() / 30000)) {
|
|
72
|
-
const key = base32Decode(secret);
|
|
73
|
-
const counter = Buffer.alloc(8);
|
|
74
|
-
counter.writeUInt32BE(Math.floor(timeStep / 2 ** 32), 0);
|
|
75
|
-
counter.writeUInt32BE(timeStep >>> 0, 4);
|
|
76
|
-
const hmac = crypto.createHmac('sha1', key).update(counter).digest();
|
|
77
|
-
const offset = hmac[hmac.length - 1] & 0xf;
|
|
78
|
-
const code = ((hmac[offset] & 0x7f) << 24) | (hmac[offset + 1] << 16) | (hmac[offset + 2] << 8) | hmac[offset + 3];
|
|
79
|
-
return String(code % 1000000).padStart(6, '0');
|
|
80
|
-
}
|
|
81
|
-
/** totp.Validate: accepts ±1 time step for clock skew, exactly 6 digits. */
|
|
82
|
-
export function totpValidate(passcode, secret) {
|
|
83
|
-
if (!/^\d{6}$/.test(passcode))
|
|
84
|
-
return false;
|
|
85
|
-
const step = Math.floor(Date.now() / 30000);
|
|
86
|
-
for (const t of [step - 1, step, step + 1]) {
|
|
87
|
-
if (totpCode(secret, t) === passcode)
|
|
88
|
-
return true;
|
|
89
|
-
}
|
|
90
|
-
return false;
|
|
91
|
-
}
|
|
92
|
-
// ---------------------------------------------------------------- storage
|
|
93
|
-
function encryptSecret(secret) {
|
|
94
|
-
return aesGcmEncrypt(secretKey(), Buffer.from(secret)).toString('base64');
|
|
95
|
-
}
|
|
96
|
-
function decryptSecret(stored) {
|
|
97
|
-
return aesGcmDecrypt(secretKey(), Buffer.from(stored, 'base64')).toString();
|
|
98
|
-
}
|
|
99
|
-
export function isTwoFactorEnabled(userID) {
|
|
100
|
-
return !!db.db().prepare('SELECT 1 FROM two_factor WHERE user_id = ?').get(userID);
|
|
101
|
-
}
|
|
102
|
-
export function getTwoFactorByUserID(userID) {
|
|
103
|
-
return db.db().prepare('SELECT * FROM two_factor WHERE user_id = ?').get(userID) ?? null;
|
|
104
|
-
}
|
|
105
|
-
export function createTwoFactor(userID, secret) {
|
|
106
|
-
const now = Math.floor(Date.now() / 1000);
|
|
107
|
-
db.db()
|
|
108
|
-
.prepare('INSERT INTO two_factor (user_id, secret, created_unix) VALUES (?,?,?) ON CONFLICT(user_id) DO UPDATE SET secret = excluded.secret')
|
|
109
|
-
.run(userID, encryptSecret(secret), now);
|
|
110
|
-
// 10 recovery codes like gogs
|
|
111
|
-
for (let i = 0; i < 10; i++) {
|
|
112
|
-
const code = randomChars(10);
|
|
113
|
-
db.db().prepare('INSERT INTO two_factor_recovery_code (user_id, code, is_used) VALUES (?,?,0)').run(userID, code.slice(0, 5).toLowerCase() + '-' + code.slice(5).toLowerCase());
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
export function deleteTwoFactor(userID) {
|
|
117
|
-
db.db().prepare('DELETE FROM two_factor WHERE user_id = ?').run(userID);
|
|
118
|
-
db.db().prepare('DELETE FROM two_factor_recovery_code WHERE user_id = ?').run(userID);
|
|
119
|
-
}
|
|
120
|
-
export function validateTOTP(userID, passcode) {
|
|
121
|
-
const t = getTwoFactorByUserID(userID);
|
|
122
|
-
if (!t)
|
|
123
|
-
return false;
|
|
124
|
-
try {
|
|
125
|
-
return totpValidate(passcode, decryptSecret(t.secret));
|
|
126
|
-
}
|
|
127
|
-
catch {
|
|
128
|
-
return false;
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
export function listRecoveryCodes(userID) {
|
|
132
|
-
return db.db().prepare('SELECT * FROM two_factor_recovery_code WHERE user_id = ? ORDER BY id').all(userID);
|
|
133
|
-
}
|
|
134
|
-
export function regenerateRecoveryCodes(userID) {
|
|
135
|
-
db.db().prepare('DELETE FROM two_factor_recovery_code WHERE user_id = ?').run(userID);
|
|
136
|
-
for (let i = 0; i < 10; i++) {
|
|
137
|
-
const code = randomChars(10);
|
|
138
|
-
db.db().prepare('INSERT INTO two_factor_recovery_code (user_id, code, is_used) VALUES (?,?,0)').run(userID, code.slice(0, 5).toLowerCase() + '-' + code.slice(5).toLowerCase());
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
export function useRecoveryCode(userID, codeInput) {
|
|
142
|
-
const code = String(codeInput).toLowerCase().trim();
|
|
143
|
-
const row = db.db().prepare('SELECT * FROM two_factor_recovery_code WHERE user_id = ? AND code = ? AND is_used = 0').get(userID, code);
|
|
144
|
-
if (!row)
|
|
145
|
-
return false;
|
|
146
|
-
db.db().prepare('UPDATE two_factor_recovery_code SET is_used = 1 WHERE id = ?').run(row.id);
|
|
147
|
-
return true;
|
|
148
|
-
}
|
|
149
|
-
// passcode reuse cache (60s) like gogs cache.TwoFactorCacheKey
|
|
150
|
-
const usedPasscodes = new Set();
|
|
151
|
-
export function passcodeRecentlyUsed(userID, passcode) {
|
|
152
|
-
const key = `${userID}:${passcode}`;
|
|
153
|
-
return usedPasscodes.has(key);
|
|
154
|
-
}
|
|
155
|
-
export function markPasscodeUsed(userID, passcode) {
|
|
156
|
-
const key = `${userID}:${passcode}`;
|
|
157
|
-
usedPasscodes.add(key);
|
|
158
|
-
setTimeout(() => usedPasscodes.delete(key), 60000);
|
|
159
|
-
}
|
|
160
|
-
//# sourceMappingURL=twofactor.js.map
|