@vzn/vx-dte 0.0.0 → 0.0.650
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/README.md +923 -1
- package/index.ts +4 -0
- package/package.json +40 -4
- package/src/cpu-account.ts +134 -0
- package/src/dispatch.ts +268 -0
- package/src/docker.ts +156 -0
- package/src/forecast.ts +68 -0
- package/src/github.ts +523 -0
- package/src/gitlab.ts +130 -0
- package/src/index.ts +12 -0
- package/src/init.ts +522 -0
- package/src/local.ts +256 -0
- package/src/messages.ts +219 -0
- package/src/noise.ts +248 -0
- package/src/orchestrator.ts +2359 -0
- package/src/outputs.ts +117 -0
- package/src/planner.ts +988 -0
- package/src/plugin.ts +1018 -0
- package/src/printable.ts +11 -0
- package/src/seal.ts +509 -0
- package/src/strategy.ts +50 -0
- package/src/trace.ts +288 -0
- package/src/tree.ts +78 -0
- package/src/usage.ts +135 -0
- package/src/warm-ups.ts +85 -0
- package/src/wire.ts +1060 -0
- package/src/worker.ts +1038 -0
package/src/noise.ts
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// The Noise Protocol Framework (noiseprotocol.org, revision 34), the two
|
|
2
|
+
// patterns a relay session uses, on WebCrypto: `NNpsk0` when the run has a
|
|
3
|
+
// key (VX_DTE_SECRET, a plan's or a per-run one), `NN` on GitHub keyless,
|
|
4
|
+
// where each side's OIDC token binds the handshake instead. X25519,
|
|
5
|
+
// AES-256-GCM, SHA-256. Held to the cacophony test vectors (tests/noise.test.ts).
|
|
6
|
+
|
|
7
|
+
const subtle = crypto.subtle
|
|
8
|
+
const HASHLEN = 32
|
|
9
|
+
/** A transport message's plaintext at most, so its ciphertext fits Noise's 65535 bytes. */
|
|
10
|
+
export const MAX_PLAINTEXT = 65535 - 16
|
|
11
|
+
/** WebCrypto takes bytes over an `ArrayBuffer` only. */
|
|
12
|
+
export type Bytes = Uint8Array<ArrayBuffer>
|
|
13
|
+
const EMPTY: Bytes = new Uint8Array(0)
|
|
14
|
+
|
|
15
|
+
export type Pattern = 'NN' | 'NNpsk0'
|
|
16
|
+
|
|
17
|
+
/** PKCS#8 for a raw X25519 private key: this prefix, then its 32 bytes. */
|
|
18
|
+
const PKCS8_PREFIX = Uint8Array.from([
|
|
19
|
+
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x6e, 0x04, 0x22, 0x04, 0x20,
|
|
20
|
+
])
|
|
21
|
+
|
|
22
|
+
interface KeyPair {
|
|
23
|
+
readonly pub: Bytes
|
|
24
|
+
readonly priv: CryptoKey
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function generate(): Promise<KeyPair> {
|
|
28
|
+
const k = (await subtle.generateKey({ name: 'X25519' }, true, [
|
|
29
|
+
'deriveBits',
|
|
30
|
+
])) as unknown as CryptoKeyPair
|
|
31
|
+
return { pub: new Uint8Array(await subtle.exportKey('raw', k.publicKey)), priv: k.privateKey }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A key pair from its raw private key: the test vectors fix each side's ephemeral. */
|
|
35
|
+
export async function fixedKey(raw: Bytes): Promise<KeyPair> {
|
|
36
|
+
const pk8 = concat(PKCS8_PREFIX, raw)
|
|
37
|
+
const priv = await subtle.importKey('pkcs8', pk8, { name: 'X25519' }, true, ['deriveBits'])
|
|
38
|
+
const { x } = (await subtle.exportKey('jwk', priv)) as { x: string }
|
|
39
|
+
return { pub: new Uint8Array(Buffer.from(x, 'base64url')), priv }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function dh(mine: KeyPair, theirs: Bytes): Promise<Bytes> {
|
|
43
|
+
const pub = await subtle.importKey('raw', theirs, { name: 'X25519' }, false, [])
|
|
44
|
+
return new Uint8Array(await subtle.deriveBits({ name: 'X25519', public: pub }, mine.priv, 256))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function concat(...parts: Bytes[]): Bytes {
|
|
48
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0))
|
|
49
|
+
let at = 0
|
|
50
|
+
for (const p of parts) {
|
|
51
|
+
out.set(p, at)
|
|
52
|
+
at += p.length
|
|
53
|
+
}
|
|
54
|
+
return out
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function hash(data: Bytes): Promise<Bytes> {
|
|
58
|
+
return new Uint8Array(await subtle.digest('SHA-256', data))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function hmac(key: Bytes, data: Bytes): Promise<Bytes> {
|
|
62
|
+
const k = await subtle.importKey('raw', key, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
|
|
63
|
+
return new Uint8Array(await subtle.sign('HMAC', k, data))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Noise's HKDF: two or three outputs from the chaining key and the input. */
|
|
67
|
+
async function hkdf(ck: Bytes, ikm: Bytes, n: 2 | 3): Promise<Bytes[]> {
|
|
68
|
+
const temp = await hmac(ck, ikm)
|
|
69
|
+
const o1 = await hmac(temp, Uint8Array.of(1))
|
|
70
|
+
const o2 = await hmac(temp, concat(o1, Uint8Array.of(2)))
|
|
71
|
+
if (n === 2) return [o1, o2]
|
|
72
|
+
return [o1, o2, await hmac(temp, concat(o2, Uint8Array.of(3)))]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** AESGCM's nonce: 32 zero bits, then the counter, big-endian. */
|
|
76
|
+
function nonce(n: number): Bytes {
|
|
77
|
+
const iv = new Uint8Array(12)
|
|
78
|
+
new DataView(iv.buffer).setBigUint64(4, BigInt(n))
|
|
79
|
+
return iv
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function aes(key: Bytes, use: 'encrypt' | 'decrypt'): Promise<CryptoKey> {
|
|
83
|
+
return subtle.importKey('raw', key, { name: 'AES-GCM' }, false, [use])
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** One direction of a session's transport: its key, and the nonce a message names. */
|
|
87
|
+
export class CipherState {
|
|
88
|
+
private enc: Promise<CryptoKey> | undefined
|
|
89
|
+
private dec: Promise<CryptoKey> | undefined
|
|
90
|
+
constructor(private readonly k: Bytes) {}
|
|
91
|
+
|
|
92
|
+
async encrypt(n: number, ad: Bytes, plaintext: Bytes): Promise<Bytes> {
|
|
93
|
+
this.enc ??= aes(this.k, 'encrypt')
|
|
94
|
+
const c = await subtle.encrypt(
|
|
95
|
+
{ name: 'AES-GCM', iv: nonce(n), additionalData: ad },
|
|
96
|
+
await this.enc,
|
|
97
|
+
plaintext,
|
|
98
|
+
)
|
|
99
|
+
return new Uint8Array(c)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Throws when it does not open. */
|
|
103
|
+
async decrypt(n: number, ad: Bytes, ciphertext: Bytes): Promise<Bytes> {
|
|
104
|
+
this.dec ??= aes(this.k, 'decrypt')
|
|
105
|
+
const p = await subtle.decrypt(
|
|
106
|
+
{ name: 'AES-GCM', iv: nonce(n), additionalData: ad },
|
|
107
|
+
await this.dec,
|
|
108
|
+
ciphertext,
|
|
109
|
+
)
|
|
110
|
+
return new Uint8Array(p)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* A handshake of `NN` or `NNpsk0`, initiator or responder:
|
|
116
|
+
* NN: -> e <- e, ee
|
|
117
|
+
* NNpsk0: -> psk, e <- e, ee
|
|
118
|
+
* Two messages; then `split()` gives what each side sends and receives with.
|
|
119
|
+
*/
|
|
120
|
+
export class Handshake {
|
|
121
|
+
private ck: Bytes = EMPTY
|
|
122
|
+
private h: Bytes = EMPTY
|
|
123
|
+
private k: CipherState | undefined
|
|
124
|
+
private n = 0
|
|
125
|
+
private e: KeyPair | undefined
|
|
126
|
+
private re: Bytes | undefined
|
|
127
|
+
private step = 0
|
|
128
|
+
|
|
129
|
+
private constructor(
|
|
130
|
+
private readonly initiator: boolean,
|
|
131
|
+
private readonly psk: Bytes | undefined,
|
|
132
|
+
private readonly fixed: KeyPair | undefined,
|
|
133
|
+
) {}
|
|
134
|
+
|
|
135
|
+
static async start(opts: {
|
|
136
|
+
readonly pattern: Pattern
|
|
137
|
+
readonly initiator: boolean
|
|
138
|
+
readonly prologue: Bytes
|
|
139
|
+
/** 32 bytes; `NNpsk0` only. */
|
|
140
|
+
readonly psk?: Bytes
|
|
141
|
+
/** Tests only: this side's ephemeral. */
|
|
142
|
+
readonly ephemeral?: KeyPair
|
|
143
|
+
}): Promise<Handshake> {
|
|
144
|
+
if ((opts.pattern === 'NNpsk0') !== (opts.psk !== undefined))
|
|
145
|
+
throw new Error('NNpsk0 takes a psk, NN none')
|
|
146
|
+
if (opts.psk !== undefined && opts.psk.length !== 32) throw new Error('a psk is 32 bytes')
|
|
147
|
+
const hs = new Handshake(opts.initiator, opts.psk, opts.ephemeral)
|
|
148
|
+
const name = new TextEncoder().encode(`Noise_${opts.pattern}_25519_AESGCM_SHA256`)
|
|
149
|
+
hs.h =
|
|
150
|
+
name.length <= HASHLEN
|
|
151
|
+
? concat(name, new Uint8Array(HASHLEN - name.length))
|
|
152
|
+
: await hash(name)
|
|
153
|
+
hs.ck = hs.h
|
|
154
|
+
await hs.mixHash(opts.prologue)
|
|
155
|
+
return hs
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The handshake hash: what both sides end with, and what an identity token binds. */
|
|
159
|
+
get hash(): Bytes {
|
|
160
|
+
return this.h
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private async mixHash(data: Bytes): Promise<void> {
|
|
164
|
+
this.h = await hash(concat(this.h, data))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private async mixKey(ikm: Bytes): Promise<void> {
|
|
168
|
+
const [ck, k] = await hkdf(this.ck, ikm, 2)
|
|
169
|
+
this.ck = ck!
|
|
170
|
+
this.k = new CipherState(k!)
|
|
171
|
+
this.n = 0
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private async mixKeyAndHash(ikm: Bytes): Promise<void> {
|
|
175
|
+
const [ck, th, k] = await hkdf(this.ck, ikm, 3)
|
|
176
|
+
this.ck = ck!
|
|
177
|
+
await this.mixHash(th!)
|
|
178
|
+
this.k = new CipherState(k!)
|
|
179
|
+
this.n = 0
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private async encryptAndHash(p: Bytes): Promise<Bytes> {
|
|
183
|
+
const c = this.k === undefined ? p : await this.k.encrypt(this.n++, this.h, p)
|
|
184
|
+
await this.mixHash(c)
|
|
185
|
+
return c
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private async decryptAndHash(c: Bytes): Promise<Bytes> {
|
|
189
|
+
const p = this.k === undefined ? c : await this.k.decrypt(this.n++, this.h, c)
|
|
190
|
+
await this.mixHash(c)
|
|
191
|
+
return p
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** `e`: a fresh ephemeral, hashed, and in a psk handshake mixed into the key. */
|
|
195
|
+
private async writeE(): Promise<Bytes> {
|
|
196
|
+
this.e = this.fixed ?? (await generate())
|
|
197
|
+
await this.mixHash(this.e.pub)
|
|
198
|
+
if (this.psk !== undefined) await this.mixKey(this.e.pub)
|
|
199
|
+
return this.e.pub
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private async readE(re: Bytes): Promise<void> {
|
|
203
|
+
this.re = re
|
|
204
|
+
await this.mixHash(re)
|
|
205
|
+
if (this.psk !== undefined) await this.mixKey(re)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** This side's next handshake message, carrying `payload`. */
|
|
209
|
+
async write(payload: Bytes = EMPTY): Promise<Bytes> {
|
|
210
|
+
if (this.initiator !== (this.step === 0)) throw new Error('not this side’s turn')
|
|
211
|
+
const parts: Bytes[] = []
|
|
212
|
+
if (this.step === 0) {
|
|
213
|
+
if (this.psk !== undefined) await this.mixKeyAndHash(this.psk)
|
|
214
|
+
parts.push(await this.writeE())
|
|
215
|
+
} else {
|
|
216
|
+
parts.push(await this.writeE())
|
|
217
|
+
await this.mixKey(await dh(this.e!, this.re!))
|
|
218
|
+
}
|
|
219
|
+
parts.push(await this.encryptAndHash(payload))
|
|
220
|
+
this.step++
|
|
221
|
+
return concat(...parts)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** The other side's handshake message: its payload. Throws when it does not open. */
|
|
225
|
+
async read(message: Bytes): Promise<Bytes> {
|
|
226
|
+
if (this.initiator === (this.step === 0)) throw new Error('not the other side’s turn')
|
|
227
|
+
if (message.length < 32) throw new Error('a handshake message too short')
|
|
228
|
+
if (this.step === 0) {
|
|
229
|
+
if (this.psk !== undefined) await this.mixKeyAndHash(this.psk)
|
|
230
|
+
await this.readE(message.subarray(0, 32))
|
|
231
|
+
} else {
|
|
232
|
+
await this.readE(message.subarray(0, 32))
|
|
233
|
+
await this.mixKey(await dh(this.e!, this.re!))
|
|
234
|
+
}
|
|
235
|
+
const payload = await this.decryptAndHash(message.subarray(32))
|
|
236
|
+
this.step++
|
|
237
|
+
return payload
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** After both messages: what this side sends with, and what it receives with. */
|
|
241
|
+
async split(): Promise<{ send: CipherState; receive: CipherState }> {
|
|
242
|
+
if (this.step !== 2) throw new Error('the handshake is not done')
|
|
243
|
+
const [k1, k2] = await hkdf(this.ck, EMPTY, 2)
|
|
244
|
+
const c1 = new CipherState(k1!)
|
|
245
|
+
const c2 = new CipherState(k2!)
|
|
246
|
+
return this.initiator ? { send: c1, receive: c2 } : { send: c2, receive: c1 }
|
|
247
|
+
}
|
|
248
|
+
}
|