// The link key — a second service account per bucket, beside the consumer's own key (novox/hq ADR 0468 §8, // issue 1594). The object-store seat signs the links (issue 1516) with this key and never with the root // account: its policy allows only s3:GetObject and s3:PutObject on the bucket's objects, so a link can read or // write an object of that bucket and do nothing else. Its secret is minio's own: a file under the state // directory (links/.secret, 0600), never in a grant, never on the bus, never in an emitted event. // // This file imports nothing of the SDK, so its tests run without the toolchain's packages. import { createHash, randomBytes } from "node:crypto"; import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; /** What the link key needs of the admin client (MinioClient has all three). */ export interface LinkAdmin { createAccessKey(bucket: string, accessKey: string, secretKey: string, policy?: string): Promise; removeAccessKey(accessKey: string): Promise; accessKeyExists(accessKey: string): Promise; } /** The access key id of a bucket's link key: "link" and the first 16 hex digits of the SHA-256 of the bucket * name (20 characters, the longest an access key may be; a bucket name may be 63). The signer of issue 1516 * derives the same id. */ export function linkKeyId(bucket: string): string { return "link" + createHash("sha256").update(bucket, "utf8").digest("hex").slice(0, 16); } /** Only get and put of the bucket's objects: no list, no delete, no bucket-level action. */ export function linkPolicy(bucket: string): string { return JSON.stringify({ Version: "2012-10-17", Statement: [{ Effect: "Allow", Action: ["s3:GetObject", "s3:PutObject"], Resource: [`arn:aws:s3:::${bucket}/*`], }], }); } /** A link key's secret: 40 hex characters, minio's limit for a secret key (issue 1766). */ const LINK_SECRET = /^[0-9a-f]{40}$/; export function linkSecretPath(dir: string, bucket: string): string { return join(dir, `${bucket}.secret`); } /** Make the bucket's link key if it is not there: the secret is kept if its file exists (so a key lost in * minio is made again under the same secret), made fresh otherwise. Idempotent: a key that exists is left. */ export async function ensureLinkKey(admin: LinkAdmin, dir: string, bucket: string): Promise { const id = linkKeyId(bucket); const path = linkSecretPath(dir, bucket); mkdirSync(dir, { recursive: true, mode: 0o700 }); let secret = existsSync(path) ? readFileSync(path, "utf8").trim() : ""; // A stored secret of the wrong shape (an earlier version made 64 characters; minio takes at most 40) is // replaced, and its key made again (issue 1766). if (!LINK_SECRET.test(secret)) secret = ""; if (secret !== "" && (await admin.accessKeyExists(id))) return; if (secret === "") { secret = randomBytes(20).toString("hex"); writeFileSync(path, secret + "\n", { mode: 0o600 }); chmodSync(path, 0o600); } try { await admin.removeAccessKey(id); } catch { /* none yet */ } await admin.createAccessKey(bucket, id, secret, linkPolicy(bucket)); } /** Revoke the bucket's link key; the bucket and the secret file's absence are left as they are. */ export async function revokeLinkKey(admin: LinkAdmin, dir: string, bucket: string): Promise { try { await admin.removeAccessKey(linkKeyId(bucket)); } catch { /* already gone */ } try { unlinkSync(linkSecretPath(dir, bucket)); } catch { /* none */ } }