Initial Commit
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user