40 lines
1.5 KiB
TypeScript
40 lines
1.5 KiB
TypeScript
import { open, seal, type EncryptionKeyring } from "@wrnexus/encryption";
|
|
import type { AuthSecretProtector } from "./types.ts";
|
|
|
|
const PURPOSE_PREFIX = "wrn-auth-secret:v1:";
|
|
|
|
type AuthSecretPurpose = "totp" | "oauth-access" | "oauth-refresh";
|
|
|
|
function bindPurpose(value: string, purpose: AuthSecretPurpose): string {
|
|
return `${PURPOSE_PREFIX}${purpose}\0${value}`;
|
|
}
|
|
|
|
function revealBoundValue(value: string, purpose: AuthSecretPurpose): string {
|
|
if (!value.startsWith(PURPOSE_PREFIX)) {
|
|
// Backward compatibility for ciphertext written before purpose binding was introduced.
|
|
return value;
|
|
}
|
|
const separator = value.indexOf("\0", PURPOSE_PREFIX.length);
|
|
if (separator < 0) throw new Error("WRN-AUTH-SECRET-PAYLOAD");
|
|
const storedPurpose = value.slice(PURPOSE_PREFIX.length, separator);
|
|
if (storedPurpose !== purpose) throw new Error("WRN-AUTH-SECRET-PURPOSE");
|
|
return value.slice(separator + 1);
|
|
}
|
|
|
|
/**
|
|
* Protect TOTP and OAuth secrets with the versioned @wrnexus/encryption keyring.
|
|
* Rotated keys continue to decrypt old records while new writes use the active key.
|
|
* New payloads are bound to their purpose so encrypted values cannot be swapped
|
|
* between TOTP, OAuth access-token, and OAuth refresh-token fields.
|
|
*/
|
|
export function createAuthSecretProtector(keyring: EncryptionKeyring): AuthSecretProtector {
|
|
return {
|
|
async protect(value, purpose) {
|
|
return seal(bindPurpose(value, purpose), keyring);
|
|
},
|
|
async reveal(value, purpose) {
|
|
return revealBoundValue(await open(value, keyring), purpose);
|
|
},
|
|
};
|
|
}
|