141 lines
4.7 KiB
TypeScript
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,
|
|
};
|
|
}
|