@vzn/vx-dte-server 0.0.0-stage → 0.0.649
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 +212 -0
- package/README.md +165 -2
- package/index.ts +4 -0
- package/package.json +61 -4
- package/src/bun.ts +107 -0
- package/src/index.ts +21 -0
- package/src/oidc.ts +307 -0
- package/src/proof.ts +82 -0
- package/src/relay.ts +785 -0
- package/src/serve.ts +75 -0
- package/src/worker.ts +250 -0
- package/wrangler.jsonc +18 -0
package/src/oidc.ts
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// CI OIDC tokens: RS256 JWTs a relay checks against each trusted issuer's
|
|
2
|
+
// published keys. The run claims bind every participant to the repository
|
|
3
|
+
// that started the run (PROTOCOL.md § Tokens). The JWT itself is jose's
|
|
4
|
+
// to check: signature, algorithm, audience and times.
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
createLocalJWKSet,
|
|
8
|
+
decodeJwt,
|
|
9
|
+
decodeProtectedHeader,
|
|
10
|
+
errors,
|
|
11
|
+
jwtVerify,
|
|
12
|
+
type JWTPayload,
|
|
13
|
+
} from 'jose'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Who may take part: jobs of `workflow` in `repository`, by tokens of
|
|
17
|
+
* `issuer`. Unset parts admit any (`@vzn/vx-dte` checks a repository by its
|
|
18
|
+
* run claim); the relay's own entry points take all three (`parseAllow`).
|
|
19
|
+
*/
|
|
20
|
+
export interface Allowed {
|
|
21
|
+
/** The full issuer URL, e.g. `https://token.actions.githubusercontent.com`. */
|
|
22
|
+
issuer: string
|
|
23
|
+
/** `owner/repo` (a GitLab project's full path), matched without case against `repositoryClaimOf(iss)`. */
|
|
24
|
+
repository?: string
|
|
25
|
+
/**
|
|
26
|
+
* The workflow file its jobs are defined in, any ref, matched without case
|
|
27
|
+
* against `workflowClaimOf(iss)`: on GitHub a file of `.github/workflows/`
|
|
28
|
+
* (`ci.yml`), on GitLab the CI config's path (`.gitlab-ci.yml`).
|
|
29
|
+
*/
|
|
30
|
+
workflow?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface OidcOptions {
|
|
34
|
+
/** Whose tokens the relay takes; any other is refused. */
|
|
35
|
+
allow: readonly Allowed[]
|
|
36
|
+
/** The `aud` a token must name. */
|
|
37
|
+
audience: string
|
|
38
|
+
/** Run claims by issuer; an issuer not named here uses `runClaimsOf`. */
|
|
39
|
+
runClaims?: Readonly<Record<string, readonly string[]>>
|
|
40
|
+
/**
|
|
41
|
+
* Reads a URL as JSON; tests serve keys without a network. `fresh`: a
|
|
42
|
+
* token named a key the last read did not have, so a cached copy will not do.
|
|
43
|
+
*/
|
|
44
|
+
fetchJson?: (url: string, fresh: boolean) => Promise<unknown>
|
|
45
|
+
/** Seconds since the epoch; tests move the clock. */
|
|
46
|
+
now?: () => number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A token the relay accepted: its issuer and the values of its run claims. */
|
|
50
|
+
export interface VerifiedToken {
|
|
51
|
+
iss: string
|
|
52
|
+
run: readonly string[]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class TokenError extends Error {
|
|
56
|
+
constructor(
|
|
57
|
+
message: string,
|
|
58
|
+
readonly status: 401 | 403 | 502,
|
|
59
|
+
) {
|
|
60
|
+
super(message)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const GITHUB = 'https://token.actions.githubusercontent.com'
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The claims a run binds: its repository, GitHub's or GitLab's. Not the
|
|
68
|
+
* pipeline: workers run in another one (a GitLab pipeline the run triggers,
|
|
69
|
+
* a GitHub workflow it dispatches), and the run's own key admits them.
|
|
70
|
+
*/
|
|
71
|
+
export function runClaimsOf(iss: string): readonly string[] {
|
|
72
|
+
return iss === GITHUB ? ['repository_id'] : ['project_id']
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The claim naming a token's repository: `owner/repo` on GitHub, the
|
|
77
|
+
* project's full path on GitLab.
|
|
78
|
+
*/
|
|
79
|
+
export function repositoryClaimOf(iss: string): string {
|
|
80
|
+
return iss === GITHUB ? 'repository' : 'project_path'
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The claim naming the workflow a token's job is defined in, `<path>@<ref>`:
|
|
85
|
+
* GitHub's `job_workflow_ref` (the reusable workflow, when the job is one),
|
|
86
|
+
* GitLab's `ci_config_ref_uri`.
|
|
87
|
+
*/
|
|
88
|
+
export function workflowClaimOf(iss: string): string {
|
|
89
|
+
return iss === GITHUB ? 'job_workflow_ref' : 'ci_config_ref_uri'
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* `ALLOW`'s entries, space separated, each
|
|
94
|
+
* `issuer:owner/repo:workflow`: read from the right, since the issuer
|
|
95
|
+
* holds `:`s of its own.
|
|
96
|
+
*/
|
|
97
|
+
export function parseAllow(list: string): Allowed[] {
|
|
98
|
+
return list
|
|
99
|
+
.split(/\s+/)
|
|
100
|
+
.filter((e) => e !== '')
|
|
101
|
+
.map((e) => {
|
|
102
|
+
// One grammar on every server: read as a separator nowhere, a comma
|
|
103
|
+
// would end up inside a workflow name no token ever carries.
|
|
104
|
+
if (e.includes(','))
|
|
105
|
+
throw new Error(`${e} is not issuer:owner/repo:workflow: entries are space separated`)
|
|
106
|
+
const w = e.lastIndexOf(':')
|
|
107
|
+
const r = e.lastIndexOf(':', w - 1)
|
|
108
|
+
const [issuer, repository, workflow] = [e.slice(0, r), e.slice(r + 1, w), e.slice(w + 1)]
|
|
109
|
+
// An entry missing a part splits elsewhere: its issuer is then no URL.
|
|
110
|
+
const url = /^[a-z][a-z0-9+.-]*:\/\/[^/]/i.test(issuer)
|
|
111
|
+
if (r < 0 || !url || !repository.includes('/') || workflow === '') {
|
|
112
|
+
throw new Error(`${e} is not issuer:owner/repo:workflow`)
|
|
113
|
+
}
|
|
114
|
+
// @vzn/vx-devtools' rule, which the cache and history servers read
|
|
115
|
+
// ALLOW with (the relay imports only jose): plain http only to
|
|
116
|
+
// 127.0.0.1's host, where a container's tests run an issuer. Its
|
|
117
|
+
// host, not its spelling: `http://127.0.0.1:80@x.example` is x.example.
|
|
118
|
+
const https =
|
|
119
|
+
issuer.startsWith('https://') ||
|
|
120
|
+
(issuer.startsWith('http://') && URL.parse(issuer)?.hostname === '127.0.0.1')
|
|
121
|
+
if (!https)
|
|
122
|
+
throw new Error(
|
|
123
|
+
`${e}: the issuer is https://, not ${issuer} (plain http only on 127.0.0.1)`,
|
|
124
|
+
)
|
|
125
|
+
// A token's iss has no trailing slash: one written in ALLOW matched none.
|
|
126
|
+
return { issuer: issuer.replace(/\/+$/, ''), repository, workflow }
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** What `workflowClaimOf(iss)` says, up to its `@`, for a job of `workflow` in `repository`. */
|
|
131
|
+
function workflowPath(iss: string, repository: string, workflow: string): string {
|
|
132
|
+
return iss === GITHUB
|
|
133
|
+
? `${repository}/.github/workflows/${workflow}`
|
|
134
|
+
: `${iss.replace(/^https?:\/\//, '').replace(/\/$/, '')}/${repository}//${workflow}`
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const same = (a: string, b: string): boolean => a.toLowerCase() === b.toLowerCase()
|
|
138
|
+
|
|
139
|
+
/** Clock skew a token's `exp` / `nbf` may be off by. */
|
|
140
|
+
const LEEWAY_S = 60
|
|
141
|
+
/** An RS256 signature: at least a 2048-bit key's 256 bytes, base64url. */
|
|
142
|
+
const SIGNATURE = /^[\w-]{342,}$/
|
|
143
|
+
|
|
144
|
+
/** An unknown `kid` refetches an issuer's keys at most this often. */
|
|
145
|
+
const REFETCH_MS = 60_000
|
|
146
|
+
/**
|
|
147
|
+
* Keys read longer ago than this are read again: a key the issuer withdrew
|
|
148
|
+
* stops signing for the relay, as jose's remote key set ages them for the
|
|
149
|
+
* cache and history servers.
|
|
150
|
+
*/
|
|
151
|
+
const KEYS_MAX_AGE_MS = 600_000
|
|
152
|
+
|
|
153
|
+
export function createVerifier(opts: OidcOptions): (token: string) => Promise<VerifiedToken> {
|
|
154
|
+
const fetchJson = opts.fetchJson ?? defaultFetchJson
|
|
155
|
+
const now = opts.now ?? (() => Date.now() / 1000)
|
|
156
|
+
const nowMs = () => now() * 1000
|
|
157
|
+
type KeySet = ReturnType<typeof createLocalJWKSet>
|
|
158
|
+
const keys = new Map<string, { at: number; set: Promise<KeySet> }>()
|
|
159
|
+
|
|
160
|
+
const load = (iss: string, fresh: boolean): { at: number; set: Promise<KeySet> } => {
|
|
161
|
+
const entry = { at: nowMs(), set: loadKeys(iss, (url) => fetchJson(url, fresh)) }
|
|
162
|
+
keys.set(iss, entry)
|
|
163
|
+
// A failed load is not kept: the next token tries again.
|
|
164
|
+
// quiet: the awaited set rethrows it; this only forgets it
|
|
165
|
+
entry.set.catch(() => keys.get(iss) === entry && keys.delete(iss))
|
|
166
|
+
return entry
|
|
167
|
+
}
|
|
168
|
+
const verifyWith = async (token: string, iss: string, fresh: boolean): Promise<JWTPayload> => {
|
|
169
|
+
const held = fresh ? undefined : keys.get(iss)
|
|
170
|
+
const entry =
|
|
171
|
+
held !== undefined && nowMs() - held.at < KEYS_MAX_AGE_MS ? held : load(iss, fresh)
|
|
172
|
+
// The issuer's outage, not the token's fault: a client ends its run on
|
|
173
|
+
// a 401, and retries a 5xx.
|
|
174
|
+
const set = await entry.set.catch((err: unknown) => {
|
|
175
|
+
throw keysUnread(iss, err)
|
|
176
|
+
})
|
|
177
|
+
const { payload } = await jwtVerify(token, set, {
|
|
178
|
+
algorithms: ['RS256'],
|
|
179
|
+
audience: opts.audience,
|
|
180
|
+
clockTolerance: LEEWAY_S,
|
|
181
|
+
currentDate: new Date(now() * 1000),
|
|
182
|
+
requiredClaims: ['exp'],
|
|
183
|
+
})
|
|
184
|
+
return payload
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return async (token) => {
|
|
188
|
+
let iss: unknown
|
|
189
|
+
let alg: unknown
|
|
190
|
+
try {
|
|
191
|
+
iss = decodeJwt(token).iss
|
|
192
|
+
// decodeJwt reads no header: one that does not decode threw out of the relay (a 500).
|
|
193
|
+
alg = decodeProtectedHeader(token).alg
|
|
194
|
+
// A token that cannot be signed costs no fetch of its issuer's keys.
|
|
195
|
+
if (!SIGNATURE.test(token.slice(token.lastIndexOf('.') + 1))) throw new Error()
|
|
196
|
+
} catch {
|
|
197
|
+
throw new TokenError('not a JWT', 401)
|
|
198
|
+
}
|
|
199
|
+
if (alg !== 'RS256') throw new TokenError('alg is not RS256', 401)
|
|
200
|
+
const ofIssuer = opts.allow.filter((a) => a.issuer === iss)
|
|
201
|
+
if (typeof iss !== 'string' || ofIssuer.length === 0) {
|
|
202
|
+
throw new TokenError(
|
|
203
|
+
typeof iss === 'string'
|
|
204
|
+
? `issuer ${iss.slice(0, 200)} is not in this relay's ALLOW: add issuer:owner/repo:workflow for it`
|
|
205
|
+
: 'the token names no issuer (iss)',
|
|
206
|
+
401,
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
let claims: JWTPayload
|
|
210
|
+
try {
|
|
211
|
+
// A key rotated in since the last read is fetched once a REFETCH_MS.
|
|
212
|
+
claims = await verifyWith(token, iss, false).catch((err: unknown) => {
|
|
213
|
+
const at = keys.get(iss)?.at ?? 0
|
|
214
|
+
if (!(err instanceof errors.JWKSNoMatchingKey) || nowMs() - at < REFETCH_MS) throw err
|
|
215
|
+
return verifyWith(token, iss, true)
|
|
216
|
+
})
|
|
217
|
+
} catch (err) {
|
|
218
|
+
throw refusal(err, iss, opts.audience)
|
|
219
|
+
}
|
|
220
|
+
let ofRepo = ofIssuer
|
|
221
|
+
if (ofIssuer.some((a) => a.repository !== undefined)) {
|
|
222
|
+
const claim = repositoryClaimOf(iss)
|
|
223
|
+
const repo = claims[claim]
|
|
224
|
+
if (typeof repo !== 'string') throw new TokenError(`no ${claim} claim`, 403)
|
|
225
|
+
ofRepo = ofIssuer.filter((a) => a.repository === undefined || same(a.repository, repo))
|
|
226
|
+
if (ofRepo.length === 0) {
|
|
227
|
+
throw new TokenError(`repository ${repo} is not in this relay's ALLOW`, 403)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (ofRepo.every((a) => a.workflow !== undefined)) {
|
|
231
|
+
const claim = workflowClaimOf(iss)
|
|
232
|
+
const ref = claims[claim]
|
|
233
|
+
if (typeof ref !== 'string') throw new TokenError(`no ${claim} claim`, 403)
|
|
234
|
+
const repo = String(claims[repositoryClaimOf(iss)])
|
|
235
|
+
// The path is the claim up to an `@` that a ref follows: a file name may hold `@` too.
|
|
236
|
+
const named = ofRepo.some((a) => {
|
|
237
|
+
const p = `${workflowPath(iss, a.repository ?? repo, a.workflow!)}@`
|
|
238
|
+
return same(ref.slice(0, p.length), p)
|
|
239
|
+
})
|
|
240
|
+
if (!named) {
|
|
241
|
+
throw new TokenError(
|
|
242
|
+
`workflow ${ref.replace(/@.*$/, '')} is not in this relay's ALLOW`,
|
|
243
|
+
403,
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const run = (opts.runClaims?.[iss] ?? runClaimsOf(iss)).map((c) => {
|
|
248
|
+
const v = claims[c]
|
|
249
|
+
if (typeof v !== 'string' && typeof v !== 'number') {
|
|
250
|
+
throw new TokenError(`no ${c} claim`, 403)
|
|
251
|
+
}
|
|
252
|
+
return String(v)
|
|
253
|
+
})
|
|
254
|
+
return { iss, run }
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** What jose's refusal of a token says, as the relay answers it. */
|
|
259
|
+
function refusal(err: unknown, iss: string, audience: string): TokenError {
|
|
260
|
+
if (err instanceof TokenError) return err
|
|
261
|
+
// A refetch of the keys for a kid not held failed: the network, not the token.
|
|
262
|
+
if (!(err instanceof errors.JOSEError) || err instanceof errors.JWKSInvalid)
|
|
263
|
+
return keysUnread(iss, err)
|
|
264
|
+
if (err instanceof errors.JOSEAlgNotAllowed) return new TokenError('alg is not RS256', 401)
|
|
265
|
+
if (err instanceof errors.JWKSNoMatchingKey) return new TokenError('unknown signing key', 401)
|
|
266
|
+
if (err instanceof errors.JWSSignatureVerificationFailed)
|
|
267
|
+
return new TokenError('bad signature', 401)
|
|
268
|
+
if (err instanceof errors.JWTExpired) return new TokenError('expired', 401)
|
|
269
|
+
if (err instanceof errors.JWTClaimValidationFailed) {
|
|
270
|
+
if (err.claim === 'aud')
|
|
271
|
+
return new TokenError(
|
|
272
|
+
`the token's aud is ${[err.payload.aud ?? 'unset'].flat().join(', ')}, not this relay's ${audience}: ask CI for a token for ${audience}, or set the relay's audience`,
|
|
273
|
+
401,
|
|
274
|
+
)
|
|
275
|
+
if (err.claim === 'nbf') return new TokenError('not yet valid', 401)
|
|
276
|
+
if (err.claim === 'exp')
|
|
277
|
+
return new TokenError(err.reason === 'missing' ? 'no exp claim' : 'expired', 401)
|
|
278
|
+
}
|
|
279
|
+
return new TokenError('not a JWT', 401)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function loadKeys(
|
|
283
|
+
iss: string,
|
|
284
|
+
fetchJson: (url: string) => Promise<unknown>,
|
|
285
|
+
): Promise<ReturnType<typeof createLocalJWKSet>> {
|
|
286
|
+
const config = (await fetchJson(
|
|
287
|
+
`${iss.replace(/\/$/, '')}/.well-known/openid-configuration`,
|
|
288
|
+
)) as {
|
|
289
|
+
jwks_uri?: unknown
|
|
290
|
+
}
|
|
291
|
+
if (typeof config.jwks_uri !== 'string') throw new Error('no jwks_uri')
|
|
292
|
+
const jwks = (await fetchJson(config.jwks_uri)) as { keys?: unknown }
|
|
293
|
+
return createLocalJWKSet({ keys: Array.isArray(jwks.keys) ? jwks.keys : [] })
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** The issuer's keys out of reach, with why: a 503 and a missing jwks_uri read the same. */
|
|
297
|
+
const keysUnread = (iss: string, err: unknown): TokenError =>
|
|
298
|
+
new TokenError(
|
|
299
|
+
`could not read ${iss}'s keys: ${err instanceof Error ? err.message : String(err)}`,
|
|
300
|
+
502,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
async function defaultFetchJson(url: string): Promise<unknown> {
|
|
304
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) })
|
|
305
|
+
if (!res.ok) throw new Error(`${url}: ${res.status}`)
|
|
306
|
+
return res.json()
|
|
307
|
+
}
|
package/src/proof.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// The orchestrator's key (PROTOCOL.md "The orchestrator's key"): its join
|
|
2
|
+
// names a P-256 public key the relay keeps for the run, and each later
|
|
3
|
+
// request as the orchestrator carries a signature by it, so only the side
|
|
4
|
+
// holding the private key acts as the run's orchestrator.
|
|
5
|
+
|
|
6
|
+
const ALG = { name: 'ECDSA', namedCurve: 'P-256' } as const
|
|
7
|
+
const SIGN = { name: 'ECDSA', hash: 'SHA-256' } as const
|
|
8
|
+
const text = new TextEncoder()
|
|
9
|
+
|
|
10
|
+
/** The header a request as the host proves itself in. */
|
|
11
|
+
export const PROOF_HEADER = 'relay-proof'
|
|
12
|
+
|
|
13
|
+
/** What a proof signs: the request, when, and a nonce the relay sees once. */
|
|
14
|
+
export function proofText(
|
|
15
|
+
method: string,
|
|
16
|
+
path: string,
|
|
17
|
+
ts: number,
|
|
18
|
+
nonce: string,
|
|
19
|
+
body: string,
|
|
20
|
+
): Uint8Array<ArrayBuffer> {
|
|
21
|
+
return text.encode(`switchboard-proof\0${method}\0${path}\0${ts}\0${nonce}\0${body}`)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A run's orchestrator key: `key` its join names, `proof` the header of each request. */
|
|
25
|
+
export interface Prover {
|
|
26
|
+
/** The public key, raw and base64url. */
|
|
27
|
+
readonly key: string
|
|
28
|
+
/** `path` is the request URL's path and query. */
|
|
29
|
+
proof(method: string, path: string, body: string): Promise<string>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A new key for one run; it never leaves this process. */
|
|
33
|
+
export async function createProver(): Promise<Prover> {
|
|
34
|
+
const pair = (await crypto.subtle.generateKey(ALG, false, ['sign', 'verify'])) as CryptoKeyPair
|
|
35
|
+
const key = b64u(new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey)))
|
|
36
|
+
return {
|
|
37
|
+
key,
|
|
38
|
+
async proof(method, path, body) {
|
|
39
|
+
const ts = Date.now()
|
|
40
|
+
const nonce = b64u(crypto.getRandomValues(new Uint8Array(16)))
|
|
41
|
+
const sig = await crypto.subtle.sign(
|
|
42
|
+
SIGN,
|
|
43
|
+
pair.privateKey,
|
|
44
|
+
proofText(method, path, ts, nonce, body),
|
|
45
|
+
)
|
|
46
|
+
return `${ts}.${nonce}.${b64u(new Uint8Array(sig))}`
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A key a join named; rejects one that is not a P-256 point. */
|
|
52
|
+
export async function importKey(raw: string): Promise<CryptoKey> {
|
|
53
|
+
return crypto.subtle.importKey('raw', unb64u(raw), ALG, false, ['verify'])
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function verifyProof(
|
|
57
|
+
key: CryptoKey,
|
|
58
|
+
sig: string,
|
|
59
|
+
signed: Uint8Array<ArrayBuffer>,
|
|
60
|
+
): Promise<boolean> {
|
|
61
|
+
// A signature that is not base64url is a bad one: atob threw out of the relay (a 500).
|
|
62
|
+
try {
|
|
63
|
+
return await crypto.subtle.verify(SIGN, key, unb64u(sig), signed)
|
|
64
|
+
} catch {
|
|
65
|
+
// quiet: a signature that cannot be read is a bad one: false is the answer
|
|
66
|
+
return false
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// No Buffer: a Worker has none.
|
|
71
|
+
function b64u(b: Uint8Array): string {
|
|
72
|
+
let s = ''
|
|
73
|
+
for (const c of b) s += String.fromCharCode(c)
|
|
74
|
+
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function unb64u(s: string): Uint8Array<ArrayBuffer> {
|
|
78
|
+
const bin = atob(s.replace(/-/g, '+').replace(/_/g, '/'))
|
|
79
|
+
const out = new Uint8Array(bin.length)
|
|
80
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
|
|
81
|
+
return out
|
|
82
|
+
}
|