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
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.
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.
- 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&*(.
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}`);
}