Files
suaveplan/packages/security-stack/crypto/crypto-vault/src/orchestration/internals.ts
2026-05-27 23:47:53 +01:00

141 lines
4.7 KiB
TypeScript

/**
* Internal helpers shared by the public {@link createRootKeys} /
* {@link createServerKeys} / {@link openVault} surface. These are NOT part of
* the package's public API — they are an implementation seam that keeps
* `orchestration.ts` under the file-size budget. Consumers always go through
* the top-level barrel.
*/
import { aeadDecrypt, aeadEncrypt } from "@suaveplan/crypto";
import {
encodePublicKeychain,
extractPublicKeys,
type Keychain,
type PublicKeychain,
} from "@suaveplan/crypto/keychain";
import { ed25519 } from "@suaveplan/crypto-curve";
import {
DEFAULT_ARGON2ID_PARAMS,
deriveKeyWithArgon2id,
} from "../argon2id/argon2id.js";
import { CRYPTO_VAULT_CODES } from "../errors/codes.js";
import { CryptoVaultError } from "../errors/crypto-vault-error.js";
import type { Argon2idParams } from "../types/types.js";
import type { LayoutV1Fields } from "./orchestration.types.js";
/** Length of the Argon2id salt embedded in the vault header. */
export const SALT_BYTES = 16;
/** Length of the XChaCha20-Poly1305 nonce embedded in the vault header. */
export const NONCE_BYTES = 24;
/** Length of an Ed25519 signature appended to the public-key file. */
export const SIGNATURE_BYTES = 64;
/** Combined header size: salt (16) + nonce (24). */
export const VAULT_HEADER_BYTES = SALT_BYTES + NONCE_BYTES;
const PASSWORD_ENCODER = new TextEncoder();
function randomBytes(length: number): Uint8Array {
const out = new Uint8Array(length);
crypto.getRandomValues(out);
return out;
}
/**
* Encrypt `plaintext` under a password-derived KEK. Layout:
* `salt(16) || nonce(24) || ciphertext`. Both header values are drawn fresh
* so two encryptions of the same plaintext under the same password share no
* bytes.
*/
export async function sealVault(
plaintext: Uint8Array,
password: string,
params: Argon2idParams
): Promise<Uint8Array> {
const salt = randomBytes(SALT_BYTES);
const nonce = randomBytes(NONCE_BYTES);
const kek = await deriveKeyWithArgon2id(
PASSWORD_ENCODER.encode(password),
salt,
params
);
const ciphertext = aeadEncrypt("XChaCha20-Poly1305", kek, plaintext, {
nonce,
});
const out = new Uint8Array(VAULT_HEADER_BYTES + ciphertext.length);
out.set(salt, 0);
out.set(nonce, SALT_BYTES);
out.set(ciphertext, VAULT_HEADER_BYTES);
return out;
}
/**
* Inverse of {@link sealVault}. Any failure — short input, wrong password, or
* tampered ciphertext — surfaces as a single
* `CryptoVaultError(CRYPTOVAULT.ORCHESTRATION.DECRYPT_FAILED)` so callers do
* not learn whether the password or the integrity tag was the failure.
*/
export async function openVaultBytes(
vaultBytes: Uint8Array,
password: string
): Promise<Uint8Array> {
if (vaultBytes.length <= VAULT_HEADER_BYTES) {
throw new CryptoVaultError(
CRYPTO_VAULT_CODES["ORCHESTRATION.DECRYPT_FAILED"],
"error.crypto-vault.orchestration.decrypt-failed",
{ context: { reason: "short-input", length: vaultBytes.length } }
);
}
const salt = vaultBytes.slice(0, SALT_BYTES);
const nonce = vaultBytes.slice(SALT_BYTES, VAULT_HEADER_BYTES);
const ciphertext = vaultBytes.slice(VAULT_HEADER_BYTES);
const kek = await deriveKeyWithArgon2id(
PASSWORD_ENCODER.encode(password),
salt,
DEFAULT_ARGON2ID_PARAMS
);
try {
return aeadDecrypt("XChaCha20-Poly1305", kek, ciphertext, { nonce });
} catch {
throw new CryptoVaultError(
CRYPTO_VAULT_CODES["ORCHESTRATION.DECRYPT_FAILED"],
"error.crypto-vault.orchestration.decrypt-failed",
{ context: { reason: "aead-verify-failed" } }
);
}
}
/**
* Sign `data` with the supplied Ed25519 secret key and append the 64-byte
* signature, producing the canonical signed public-key file layout
* `data || signature`.
*/
function signAndAppend(
data: Uint8Array,
ed25519SecretKey: Uint8Array
): Uint8Array {
const signature = ed25519.sign(data, ed25519SecretKey);
const out = new Uint8Array(data.length + signature.length);
out.set(data, 0);
out.set(signature, data.length);
return out;
}
/**
* Project `keychain` to its public-only counterpart, encode, sign, and
* package both forms for callers.
*/
export function buildPublicKeyFile(
keychain: Keychain<LayoutV1Fields>,
signingSlotSecretKey: Uint8Array
): {
publicKeyBytes: Uint8Array;
publicKeys: PublicKeychain<LayoutV1Fields>;
} {
const publicKeys = extractPublicKeys(keychain);
const encoded = encodePublicKeychain(publicKeys);
return {
publicKeyBytes: signAndAppend(encoded, signingSlotSecretKey),
publicKeys,
};
}