65 lines
3.2 KiB
TypeScript
65 lines
3.2 KiB
TypeScript
// 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 */ }
|
|
}
|