Make a link key per bucket, limited to get and put of its objects and kept as minio's own secret, so links are not signed by the root account (issue 1594)

This commit is contained in:
2026-10-21 14:47:14 +02:00
parent 671ab9a3ba
commit 5b3fd1008a
8 changed files with 297 additions and 57 deletions
+64
View File
@@ -0,0 +1,64 @@
// 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/<bucket>.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<unknown>;
removeAccessKey(accessKey: string): Promise<void>;
accessKeyExists(accessKey: string): Promise<boolean>;
}
/** 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}/*`],
}],
});
}
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<void> {
const id = linkKeyId(bucket);
const path = linkSecretPath(dir, bucket);
mkdirSync(dir, { recursive: true, mode: 0o700 });
let secret = existsSync(path) ? readFileSync(path, "utf8").trim() : "";
if (secret !== "" && (await admin.accessKeyExists(id))) return;
if (secret === "") {
secret = randomBytes(32).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<void> {
try { await admin.removeAccessKey(linkKeyId(bucket)); } catch { /* already gone */ }
try { unlinkSync(linkSecretPath(dir, bucket)); } catch { /* none */ }
}