Skip to content

Instantly share code, notes, and snippets.

@VityaSchel
Last active August 27, 2026 10:47
Show Gist options
  • Select an option

  • Save VityaSchel/839d55870040b1833676ad04c1dbe415 to your computer and use it in GitHub Desktop.

Select an option

Save VityaSchel/839d55870040b1833676ad04c1dbe415 to your computer and use it in GitHub Desktop.
Forgejo Actions runners secrets pull & dump decryption

Forgejo Actions runners secrets pull & dump decryption

Where CI secrets are stored

Table secret, columns id, owner_id, repo_id, name, data, created_unix. Scoping: org/user secret -> owner_id set, repo_id=0; repo secret -> owner_id=0, repo_id set. Global secrets don't exist. data is bytea

How encryption works

Forgejo >= 13.0:

prk    = HKDF-Extract(SHA256, ikm=SECRET_KEY, salt=∅)
subkey = HKDF-Expand(prk, info="action_secret", 32)
blob   = nonce[24] ‖ XChaCha20-Poly1305(subkey, nonce, plaintext, aad)
aad    = "data:" ‖ big-endian uint64(secret.id)

The row id is authenticated data, so you cannot decrypt a data blob without its id.

Forgejo < 13.0 / all Gitea:

Data is text holding hex: AES-256-CFB(key=SHA256(SECRET_KEY), iv=first 16 bytes) over a base64 of the plaintext.

Check SELECT data FROM secret LIMIT 1 — \x... bytea means new scheme, plain hex text means legacy.

Encryption/decryption key

The key is in app.ini -> [security] SECRET_KEY or SECRET_KEY_URI = file:/path. If you haven't set the secret key, it's literally hardcoded default !#@FDEWREWR&*( (modules/setting/security.go:429-436). INTERNAL_TOKEN is unrelated.

1. Pull data

  • Pull secrets dump:
# replace "forgejo" with your Forgejo's DB name
psql -At -F$'\t' \d forgejo -c "select s.id, coalesce(r.owner_name||'/'||r.name, u.name, '?')||' :: '||s.name, s.data \
  from secret s
  left join repository r on r.id = s.repo_id
  left join \"user\" u on u.id = s.owner_id
  order by s.id"
  • Pull encryption key:

Check app.ini -> find "SECRET_KEY", if it's not there, use the default !#@FDEWREWR&*(.

2. Decrypt

Below is a Bun/TS script for reference. It uses my beloved noble so as to not re-implement common crypto

import { createDecipheriv, hkdfSync } from "node:crypto";
import { xchacha20poly1305 } from "@noble/ciphers/chacha.js";
import {
	bytesToUtf8,
	concatBytes,
	numberToBytesBE,
	utf8ToBytes,
} from "@noble/ciphers/utils.js";

// ============ EDIT THIS ============

const secretKey = "!#@FDEWREWR&*(";
const input = String.raw`paste your input here`;

// ===================================

/** Mirrors keying.ColumnAndID: []byte(column) || ':' || big-endian uint64(id). */
function columnAndID(column: string, id: bigint): Uint8Array {
	return concatBytes(
		utf8ToBytes(`${column}:`),
		numberToBytesBE(BigInt.asUintN(64, id), 8),
	);
}

function decodeHex(hex: string) {
	return Uint8Array.fromHex(hex.trim().replace(/^\\x/, ""));
}

/** Forgejo >= 13.0 */
export function decryptActionSecret(
	secretKey: string,
	id: bigint,
	data: Uint8Array,
): string {
	const NONCE_SIZE = 24;
	const TAG_SIZE = 16;
	if (data.length < NONCE_SIZE + TAG_SIZE)
		throw new Error("ciphertext too short");
	const nonce = data.subarray(0, NONCE_SIZE);
	const sealed = data.subarray(NONCE_SIZE);
	const subkey = new Uint8Array(
		hkdfSync(
			"sha256",
			utf8ToBytes(secretKey),
			new Uint8Array(32),
			"action_secret",
			32,
		),
	);
	const aead = xchacha20poly1305(subkey, nonce, columnAndID("data", id));
	return bytesToUtf8(aead.decrypt(sealed));
}

/** Forgejo < 13.0 and all Gitea */
export function decryptActionSecretLegacy(
	secretKey: string,
	cipherHex: string,
): string {
	const AES_BLOCK_SIZE = 16;
	const keyHash = new Bun.CryptoHasher("sha256")
		.update(secretKey, "utf8")
		.digest();
	const raw = decodeHex(cipherHex);
	if (raw.length < AES_BLOCK_SIZE) throw new Error("ciphertext too short");
	const decipher = createDecipheriv(
		"aes-256-cfb",
		keyHash,
		raw.subarray(0, AES_BLOCK_SIZE),
	);
	const b64 =
	decipher.update(raw.subarray(AES_BLOCK_SIZE), undefined, "latin1") +
	decipher.final("latin1");
	// Go decodes with base64.StdEncoding, which rejects unpadded/partial groups
	if (
		!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(
			b64,
		)
	)
	throw new Error("wrong SECRET_KEY (decrypted data is not base64)");
	return bytesToUtf8(Uint8Array.fromBase64(b64));
}

function parseID(idStr: string | undefined): bigint {
	if (idStr === undefined || !/^-?\d+$/.test(idStr.trim()))
		throw new Error(`invalid secret id ${JSON.stringify(idStr)}`);
	return BigInt(idStr.trim());
}

for (const rawLine of input.split("\n")) {
	const line = rawLine.replace(/\r$/, "");
	if (!line.trim()) continue;
	const [idStr, name, value] = line.split("\t");
	const label = name ?? idStr ?? "?";
	
	if (value === undefined)
		throw new Error("expected tab-separated id, name and data");
	
	let plaintext: string;
	try {
		plaintext = decryptActionSecret(
			secretKey,
			parseID(idStr),
			decodeHex(value),
		);
	} catch {
		plaintext = decryptActionSecretLegacy(secretKey, value);
	}
	console.log(`${label}\t${plaintext}`);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment