import assert from "node:assert/strict"; import { test } from "node:test"; import { mkdtempSync, readFileSync, statSync, existsSync, readdirSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; // Issue 1594: a link key per bucket (novox/hq ADR 0468 ยง8). The fake admin below stands for minio's service // accounts; the real client's policies come from client.ts's bucketPolicy, which the tests import. import { bucketPolicy } from "../client.ts"; import { ensureLinkKey, linkKeyId, linkSecretPath } from "../links.ts"; import { handlers } from "../provisioner/handlers.ts"; class FakeAdmin { baseUrl = "http://127.0.0.1:9000"; buckets = new Set(); keys = new Map(); created: string[] = []; async bucketExists(b: string) { return this.buckets.has(b); } async createBucket(b: string) { this.buckets.add(b); } async canReachAs() { return true; } async accessKeyExists(id: string) { return this.keys.has(id); } async removeAccessKey(id: string) { if (!this.keys.delete(id)) throw new Error("no such key"); } async createAccessKey(bucket: string, id: string, secret: string, policy: string = bucketPolicy(bucket)) { this.keys.set(id, { bucket, secret, policy }); this.created.push(id); return { accessKey: id, secretKey: secret }; } } function setup() { const minio = new FakeAdmin(); const dir = join(mkdtempSync(join(process.env.TMPDIR ?? tmpdir(), "minio-links-")), "links"); const events: Array<{ type: string; body: unknown }> = []; const h = handlers({ minio, linksDir: dir, announce: async (type, body) => { events.push({ type, body }); } }); return { minio, dir, events, h }; } const provision = { as: "brother-key", password: "consumer-secret", consumer: "brother", derived: { bucket: "brother" } } as any; test("creating a consumer's bucket makes a link key allowed only get and put of the bucket's objects, its secret in links/.secret at 0600", async () => { // 1594/AC1 const { minio, dir, h } = setup(); await h.create(provision); const link = minio.keys.get(linkKeyId("brother")); assert.ok(link, "a link key exists for the bucket"); const policy = JSON.parse(link.policy); assert.equal(policy.Statement.length, 1); assert.deepEqual(policy.Statement[0].Action, ["s3:GetObject", "s3:PutObject"]); assert.deepEqual(policy.Statement[0].Resource, ["arn:aws:s3:::brother/*"]); const path = linkSecretPath(dir, "brother"); assert.equal(statSync(path).mode & 0o777, 0o600); assert.equal(readFileSync(path, "utf8").trim(), link.secret); assert.ok(link.secret.length >= 32); }); test("buckets that existed before get a link key at start, and a second start makes none twice", async () => { // 1594/AC2 const { minio, dir } = setup(); for (const b of ["brother", "mesh-mail", "nextcloud", "invoicing"]) minio.buckets.add(b); for (const b of minio.buckets) await ensureLinkKey(minio, dir, b); assert.equal(minio.keys.size, 4); const first = [...minio.created]; const secret = readFileSync(linkSecretPath(dir, "nextcloud"), "utf8"); for (const b of minio.buckets) await ensureLinkKey(minio, dir, b); assert.deepEqual(minio.created, first, "no key made a second time"); assert.equal(readFileSync(linkSecretPath(dir, "nextcloud"), "utf8"), secret, "the secret is kept"); }); test("a link key lost in minio is made again under the kept secret", async () => { // 1594/AC2 const { minio, dir } = setup(); await ensureLinkKey(minio, dir, "brother"); const secret = minio.keys.get(linkKeyId("brother"))!.secret; minio.keys.clear(); await ensureLinkKey(minio, dir, "brother"); assert.equal(minio.keys.get(linkKeyId("brother"))!.secret, secret); }); test("removing a consumer revokes its bucket's link key and keeps the bucket", async () => { // 1594/AC3 const { minio, dir, h } = setup(); await h.create(provision); assert.ok(minio.keys.has(linkKeyId("brother"))); await h.remove({ as: "brother-key", derived: { bucket: "brother" } }); assert.ok(!minio.keys.has(linkKeyId("brother")), "link key revoked"); assert.ok(!minio.keys.has("brother-key"), "consumer key revoked"); assert.ok(minio.buckets.has("brother"), "bucket kept"); assert.ok(!existsSync(linkSecretPath(dir, "brother")), "secret file removed"); }); test("the consumer's own key keeps its s3:* policy, and the link key's secret is in no event", async () => { // 1594/AC4 const { minio, dir, events, h } = setup(); await h.create(provision); const own = minio.keys.get("brother-key")!; assert.equal(own.secret, "consumer-secret"); assert.equal(own.policy, bucketPolicy("brother")); assert.deepEqual(JSON.parse(own.policy).Statement[0].Action, ["s3:*"]); const linkSecret = minio.keys.get(linkKeyId("brother"))!.secret; await h.remove({ as: "brother-key", derived: { bucket: "brother" } }); assert.ok(events.length >= 2); for (const e of events) assert.ok(!JSON.stringify(e).includes(linkSecret), `${e.type} carries no link secret`); assert.deepEqual(readdirSync(dir), [], "nothing else is kept in the links directory"); }); test("a link key id fits an access key (20 characters) whatever the bucket name", () => { assert.equal(linkKeyId("a".repeat(63)).length, 20); assert.notEqual(linkKeyId("brother"), linkKeyId("mesh-mail")); }); test("the link key id is the one the object-store tool derives (cmd/object-store server_test.go holds the same literal)", () => { // 1516/AC1 assert.equal(linkKeyId("brother"), "link0b4bd77cec705cf5"); }); // Issue 1766: a link key's secret is minio's length, and no mc failure carries an argument's secret. import { chmodSync, writeFileSync, mkdirSync } from "node:fs"; import { MinioClient } from "../client.ts"; test("a link key's secret is 40 characters, and a stored 64-character secret is replaced", async () => { // 1766/AC1 const { minio, dir } = setup(); await ensureLinkKey(minio, dir, "fresh"); assert.equal(minio.keys.get(linkKeyId("fresh"))!.secret.length, 40); assert.match(minio.keys.get(linkKeyId("fresh"))!.secret, /^[0-9a-f]{40}$/); mkdirSync(dir, { recursive: true }); const old = "ab".repeat(32); writeFileSync(linkSecretPath(dir, "old"), old + "\n"); await ensureLinkKey(minio, dir, "old"); const made = minio.keys.get(linkKeyId("old"))!.secret; assert.equal(made.length, 40); assert.notEqual(made, old); assert.equal(readFileSync(linkSecretPath(dir, "old"), "utf8").trim(), made); }); function failingMc(): { client: MinioClient; root: string } { const d = mkdtempSync(join(process.env.TMPDIR ?? tmpdir(), "minio-mc-")); const bin = join(d, "mc"); // Says its arguments and its alias on stderr, then fails, the way a refused command does. writeFileSync(bin, '#!/bin/sh\necho "args: $*" >&2\necho "env: MC_HOST_mesh=$MC_HOST_mesh" >&2\nexit 1\n'); chmodSync(bin, 0o755); const root = "RootPass-s3cret/ok"; const client = new MinioClient({ endpoint: "http://127.0.0.1:9000", rootUser: "root", rootPassword: root, region: "us-east-1", mcBin: bin, mcConfigDir: d }); return { client, root }; } test("an mc failure's message holds no --secret-key value and no MC_HOST value", async () => { // 1766/AC2 const { client, root } = failingMc(); const secret = "SuperSecretValue0123456789"; const err: any = await client.createAccessKey("b", "ACCESSKEYID1234567", secret).then(() => null, (e) => e); assert.ok(err, "the failure is raised"); for (const text of [String(err.message), String(err.stack ?? ""), JSON.stringify(Object.getOwnPropertyNames(err).map((k) => err[k]))]) { assert.ok(!text.includes(secret), "no secret key"); assert.ok(!text.includes("ACCESSKEYID1234567"), "no access key"); assert.ok(!text.includes(root) && !text.includes(encodeURIComponent(root)), "no root password"); assert.ok(!text.includes("root:"), "no MC_HOST value"); } assert.match(err.message, /\[redacted\]/); }); test("a consumer key's create passes the minted secret unchanged", async () => { // 1766/AC3 const s = setup(); await s.h.create(provision); assert.equal(s.minio.keys.get("brother-key")!.secret, "consumer-secret"); });