dsh-computers 0.0.0-stage → 1.3.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 +21 -0
- package/PROTOCOL.md +694 -0
- package/README.md +176 -2
- package/agent/README.md +383 -0
- package/agent/dsh-computers-agent.mjs +2808 -0
- package/client.js +3986 -0
- package/cordis.patch.yml +25 -0
- package/docs/deploy.md +103 -0
- package/icon.svg +8 -0
- package/index.js +241 -0
- package/lib/access.js +397 -0
- package/lib/fs-ops.js +162 -0
- package/lib/http.js +761 -0
- package/lib/hub.js +587 -0
- package/lib/keys.js +134 -0
- package/lib/routes.js +1084 -0
- package/lib/runtime.js +469 -0
- package/lib/selection.js +205 -0
- package/lib/state.js +190 -0
- package/lib/tools.js +993 -0
- package/lib/version.js +62 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +56 -4
package/lib/keys.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secrets and small value helpers for dsh-computers (PROTOCOL §2, §3.1).
|
|
3
|
+
*
|
|
4
|
+
* Enrollment keys and agent tokens share one shape — `dshc_` plus 32 random
|
|
5
|
+
* bytes as base64url — but have different lifetimes: the key is single use
|
|
6
|
+
* with a TTL, the token is long lived and the host stores only its sha256.
|
|
7
|
+
*
|
|
8
|
+
* Node builtins only.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
12
|
+
|
|
13
|
+
/** Every secret this plugin mints carries this prefix. */
|
|
14
|
+
export const SECRET_PREFIX = 'dshc_'
|
|
15
|
+
|
|
16
|
+
/** Token bytes per secret (base64url-encodes to 43 characters). */
|
|
17
|
+
const SECRET_BYTES = 32
|
|
18
|
+
|
|
19
|
+
/** Mint one `dshc_<43 base64url>` secret. */
|
|
20
|
+
export function mintSecret() {
|
|
21
|
+
return `${SECRET_PREFIX}${randomBytes(SECRET_BYTES).toString('base64url')}`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Mint one computer id: `cmp_` plus 12 lowercase hex characters. */
|
|
25
|
+
export function newComputerId() {
|
|
26
|
+
return `cmp_${randomBytes(6).toString('hex')}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Mint one exec request id: `req_` plus 24 hex characters. */
|
|
30
|
+
export function newRequestId() {
|
|
31
|
+
return `req_${randomBytes(12).toString('hex')}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* sha256 of a secret as lowercase hex — the only form the host persists.
|
|
36
|
+
* @param {string} value - secret or arbitrary string.
|
|
37
|
+
* @returns {string} 64 hex characters.
|
|
38
|
+
*/
|
|
39
|
+
export function sha256Hex(value) {
|
|
40
|
+
return createHash('sha256').update(String(value), 'utf8').digest('hex')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Compare a presented token against a stored sha256 hash in constant time.
|
|
45
|
+
* Digests always have the same length, so `timingSafeEqual` cannot throw on
|
|
46
|
+
* the comparison itself; a malformed stored hash simply fails closed.
|
|
47
|
+
* @param {string} presented - bearer token from the request.
|
|
48
|
+
* @param {string} expectedHash - stored `tokenHash`.
|
|
49
|
+
* @returns {boolean} whether the token matches.
|
|
50
|
+
*/
|
|
51
|
+
export function tokenMatches(presented, expectedHash) {
|
|
52
|
+
if (typeof presented !== 'string' || typeof expectedHash !== 'string') return false
|
|
53
|
+
let expected
|
|
54
|
+
try {
|
|
55
|
+
expected = Buffer.from(expectedHash, 'hex')
|
|
56
|
+
} catch {
|
|
57
|
+
return false
|
|
58
|
+
}
|
|
59
|
+
if (expected.length !== 32) return false
|
|
60
|
+
const actual = createHash('sha256').update(presented, 'utf8').digest()
|
|
61
|
+
return timingSafeEqual(actual, expected)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Extract a bearer token from an `authorization` header.
|
|
66
|
+
* @param {import('node:http').IncomingMessage} req - incoming request.
|
|
67
|
+
* @returns {string | null} the token, or null when the header is absent/malformed.
|
|
68
|
+
*/
|
|
69
|
+
export function bearerToken(req) {
|
|
70
|
+
const header = req?.headers?.authorization
|
|
71
|
+
if (typeof header !== 'string') return null
|
|
72
|
+
const match = /^bearer[ \t]+(\S+)[ \t]*$/i.exec(header.trim())
|
|
73
|
+
return match === null ? null : match[1]
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Whether an ISO timestamp is at or before `nowMs`.
|
|
78
|
+
* @param {string} iso - timestamp to test.
|
|
79
|
+
* @param {number} nowMs - current epoch milliseconds.
|
|
80
|
+
* @returns {boolean} true when the timestamp is missing/unparseable or in the past.
|
|
81
|
+
*/
|
|
82
|
+
export function isExpired(iso, nowMs) {
|
|
83
|
+
if (typeof iso !== 'string') return true
|
|
84
|
+
const at = Date.parse(iso)
|
|
85
|
+
return !Number.isFinite(at) || at <= nowMs
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Normalize a display name to 1–64 visible characters (trimmed, control
|
|
90
|
+
* characters removed). Returns null when the result is unacceptable, so the
|
|
91
|
+
* caller can answer 400 instead of storing garbage.
|
|
92
|
+
* @param {unknown} raw - candidate from a request body.
|
|
93
|
+
* @returns {string | null} the normalized name.
|
|
94
|
+
*/
|
|
95
|
+
export function normalizeDisplayName(raw) {
|
|
96
|
+
if (typeof raw !== 'string') return null
|
|
97
|
+
const cleaned = raw.replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim()
|
|
98
|
+
if (cleaned === '') return null
|
|
99
|
+
if ([...cleaned].length > 64) return null
|
|
100
|
+
return cleaned
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Human platform label for a Node `os.platform()` value. */
|
|
104
|
+
export function platformLabel(osName) {
|
|
105
|
+
switch (String(osName)) {
|
|
106
|
+
case 'darwin': return 'macOS'
|
|
107
|
+
case 'linux': return 'Linux'
|
|
108
|
+
case 'win32': return 'Windows'
|
|
109
|
+
default: return 'Unknown'
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Coerce an optional string field from a request body.
|
|
115
|
+
* @param {unknown} value - candidate.
|
|
116
|
+
* @param {number} maxLength - inclusive character cap.
|
|
117
|
+
* @returns {string | null} the value or null.
|
|
118
|
+
*/
|
|
119
|
+
export function optionalString(value, maxLength = 256) {
|
|
120
|
+
if (typeof value !== 'string') return null
|
|
121
|
+
const trimmed = value.trim()
|
|
122
|
+
if (trimmed === '' || trimmed.length > maxLength) return null
|
|
123
|
+
return trimmed
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Coerce an optional non-negative integer field from a request body.
|
|
128
|
+
* @param {unknown} value - candidate.
|
|
129
|
+
* @returns {number | null} the value or null.
|
|
130
|
+
*/
|
|
131
|
+
export function optionalCount(value) {
|
|
132
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) return null
|
|
133
|
+
return value
|
|
134
|
+
}
|