| 1 | /** |
| 2 | * Run credentials: the tokens a sandbox works with. Each is bound to its |
| 3 | * run, its repository and what that kind of run needs, acts on behalf of |
| 4 | * the person (or g1t itself) the work is for, and is revoked the moment |
| 5 | * the sandbox stops. See `crates/contracts/src/credentials.rs` for what |
| 6 | * each kind of run may do. |
| 7 | */ |
| 8 | |
| 9 | import type { CreateRunCredentialInput, ServiceBinding } from "@g1t/contracts"; |
| 10 | |
| 11 | /** The environment variable a sandbox's g1t token is passed in. */ |
| 12 | export const CREDENTIAL_VARS = ["G1T_TOKEN"] as const; |
| 13 | |
| 14 | const STORAGE_KEY = "credentials"; |
| 15 | |
| 16 | /** Identity's JSON protocol, as the contracts' client speaks it. */ |
| 17 | async function call<T>(identity: ServiceBinding, method: string, args: object): Promise<T> { |
| 18 | const response = await identity.fetch(`https://service/rpc/${method}`, { |
| 19 | method: "POST", |
| 20 | headers: { "content-type": "application/json" }, |
| 21 | body: JSON.stringify(args), |
| 22 | }); |
| 23 | if (!response.ok) throw new Error(`${method} failed with status ${response.status}`); |
| 24 | return (await response.json()) as T; |
| 25 | } |
| 26 | |
| 27 | /** A run credential's text. */ |
| 28 | export async function runCredential(identity: ServiceBinding, input: CreateRunCredentialInput): Promise<string> { |
| 29 | const { token } = await call<{ token: string }>(identity, "create_run_credential", input); |
| 30 | return token; |
| 31 | } |
| 32 | |
| 33 | /** SHA-256 in lowercase hex, as identity stores tokens. */ |
| 34 | export async function sha256Hex(text: string): Promise<string> { |
| 35 | const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)); |
| 36 | return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); |
| 37 | } |
| 38 | |
| 39 | /** The hashes of the g1t tokens among a sandbox's variables. */ |
| 40 | export async function credentialHashes(envVars: Record<string, string>): Promise<string[]> { |
| 41 | const tokens = CREDENTIAL_VARS.map((name) => envVars[name]).filter( |
| 42 | (value): value is string => typeof value === "string" && value.startsWith("g1t_"), |
| 43 | ); |
| 44 | return Promise.all(tokens.map(sha256Hex)); |
| 45 | } |
| 46 | |
| 47 | type Storage = { |
| 48 | put(key: string, value: unknown): Promise<void>; |
| 49 | get<T>(key: string): Promise<T | undefined>; |
| 50 | delete(key: string): Promise<boolean>; |
| 51 | }; |
| 52 | |
| 53 | /** |
| 54 | * Remembers a sandbox's credentials, by hash, so they can be revoked when |
| 55 | * it stops, and ties them to the run it recorded. Never stops the sandbox |
| 56 | * from starting. |
| 57 | */ |
| 58 | export async function holdCredentials( |
| 59 | identity: ServiceBinding, |
| 60 | storage: Storage, |
| 61 | envVars: Record<string, string>, |
| 62 | runId: string | null, |
| 63 | ): Promise<void> { |
| 64 | const hashes = await credentialHashes(envVars); |
| 65 | if (hashes.length === 0) return; |
| 66 | await storage.put(STORAGE_KEY, hashes); |
| 67 | if (!runId) return; |
| 68 | await call(identity, "bind_run_credentials", { tokenHashes: hashes, runId }).catch((error: unknown) => console.log("run credentials not bound", runId, String(error))); |
| 69 | } |
| 70 | |
| 71 | /** Ends a sandbox's g1t credentials, once. */ |
| 72 | export async function revokeCredentials(identity: ServiceBinding, storage: Storage): Promise<void> { |
| 73 | const hashes = await storage.get<string[]>(STORAGE_KEY); |
| 74 | if (!hashes?.length) return; |
| 75 | await storage.delete(STORAGE_KEY); |
| 76 | await call(identity, "revoke_run_credentials", { tokenHashes: hashes }).catch((error: unknown) => console.log("run credentials not revoked", String(error))); |
| 77 | } |