280 lines
9.2 KiB
TypeScript
280 lines
9.2 KiB
TypeScript
/**
|
|
* High-level orchestration helpers for `@suaveplan/crypto-vault`.
|
|
*
|
|
* These functions compose the package's own Argon2id KEK derivation with the
|
|
* primitives exposed by `@suaveplan/crypto` (keychain generation, public-key
|
|
* encoding) and `@suaveplan/crypto-curve` (Ed25519 signing) to produce the
|
|
* single-call surface that `@suaveplan/server-keys` operator bins rely on.
|
|
*
|
|
* Wire formats are genesis-native (NOT byte-compatible with the upstream
|
|
* `@suaveplan/keys` BitPacker-based format). A migration command from old to
|
|
* new lives in a separate package (`@suaveplan/keys-migration`, follow-up).
|
|
*/
|
|
|
|
import {
|
|
decodeKeychain,
|
|
decodePublicKeychain,
|
|
encodeKeychain,
|
|
generateKeychain,
|
|
type Keychain,
|
|
type PublicKeychain,
|
|
} from "@suaveplan/crypto/keychain";
|
|
import { ed25519 } from "@suaveplan/crypto-curve";
|
|
import type { AbstractStorage } from "@suaveplan/storage";
|
|
import { DEFAULT_ARGON2ID_PARAMS } from "../argon2id/argon2id.js";
|
|
import { CRYPTO_VAULT_CODES } from "../errors/codes.js";
|
|
import { CryptoVaultError } from "../errors/crypto-vault-error.js";
|
|
import { deriveChildKey } from "../hd-key/hd-key.js";
|
|
import type { Argon2idParams } from "../types/types.js";
|
|
import {
|
|
buildPublicKeyFile,
|
|
openVaultBytes,
|
|
SIGNATURE_BYTES,
|
|
sealVault,
|
|
} from "./internals.js";
|
|
import type {
|
|
CreateRootKeysOptions,
|
|
CreateServerKeysOptions,
|
|
LayoutV1Fields,
|
|
PackVaultOptions,
|
|
RollServerKeysOptions,
|
|
RootKeysResult,
|
|
ServerKeysResult,
|
|
VaultEncryptionOptions,
|
|
} from "./orchestration.types.js";
|
|
|
|
const DEFAULT_ROOT_LAYOUT: LayoutV1Fields = Object.freeze({
|
|
masterVersion: 1,
|
|
userId: 0,
|
|
depth: 0,
|
|
slotVersion: 1,
|
|
});
|
|
|
|
type DefaultSlot = Keychain<LayoutV1Fields>[keyof Keychain<LayoutV1Fields>];
|
|
|
|
function getSlot(
|
|
keychain: Keychain<LayoutV1Fields>,
|
|
purpose: "mast" | "sign"
|
|
): DefaultSlot {
|
|
// `Keychain<LayoutV1Fields>` is `Readonly<Record<PurposeTag, KeySlotPrivate>>`.
|
|
// The named string keys ("master", "sign", …) are exactly the eight default
|
|
// purposes — always present after `generateKeychain` — but TS can't narrow
|
|
// brand-typed Record keys via dot access, so we go through a string-keyed
|
|
// view. The `Required` wrapper drops the `| undefined` introduced by index
|
|
// signatures under `noUncheckedIndexedAccess`.
|
|
const view = keychain as unknown as Record<string, DefaultSlot>;
|
|
return view[purpose] as DefaultSlot;
|
|
}
|
|
|
|
function defaultClock(
|
|
options: VaultEncryptionOptions | undefined
|
|
): () => number {
|
|
return options?.clock ?? Date.now;
|
|
}
|
|
|
|
function getArgon2idParams(
|
|
options: VaultEncryptionOptions | undefined
|
|
): Argon2idParams {
|
|
return options?.argon2id ?? DEFAULT_ARGON2ID_PARAMS;
|
|
}
|
|
|
|
function assertNonNegativeInteger(field: string, value: number): void {
|
|
if (!Number.isInteger(value) || value < 0) {
|
|
throw new CryptoVaultError(
|
|
CRYPTO_VAULT_CODES["ORCHESTRATION.INVALID_VERSION"],
|
|
"error.crypto-vault.orchestration.invalid-version",
|
|
{ context: { field, value } }
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate a fresh root keychain, encrypt it under a password, and produce
|
|
* the matching signed public-key file in one step. The returned `vaultBytes`
|
|
* round-trip through {@link openVault} with the same password.
|
|
*/
|
|
export async function createRootKeys(
|
|
password: string,
|
|
options?: CreateRootKeysOptions
|
|
): Promise<RootKeysResult> {
|
|
const clock = defaultClock(options);
|
|
const layoutFields = options?.layoutFields ?? DEFAULT_ROOT_LAYOUT;
|
|
const keychain = generateKeychain({
|
|
clock,
|
|
layoutFields,
|
|
...(options?.masterKey !== undefined
|
|
? { masterKey: options.masterKey }
|
|
: {}),
|
|
});
|
|
const vaultBytes = await packVault(keychain, password, options);
|
|
const { publicKeyBytes, publicKeys } = buildPublicKeyFile(
|
|
keychain,
|
|
getSlot(keychain, "sign").secretKey
|
|
);
|
|
return { keychain, vaultBytes, publicKeyBytes, publicKeys };
|
|
}
|
|
|
|
async function buildServerResult(
|
|
rootKeys: RootKeysResult,
|
|
serverPassword: string,
|
|
version: number,
|
|
options: CreateServerKeysOptions | undefined
|
|
): Promise<ServerKeysResult> {
|
|
const clock = defaultClock(options);
|
|
const layoutFields: LayoutV1Fields = {
|
|
masterVersion: version,
|
|
userId: options?.userId ?? 1,
|
|
depth: 1,
|
|
slotVersion: 1,
|
|
};
|
|
const childMaster = deriveChildKey(
|
|
getSlot(rootKeys.keychain, "mast").secretKey,
|
|
version
|
|
);
|
|
const keychain = generateKeychain({
|
|
clock,
|
|
layoutFields,
|
|
masterKey: childMaster,
|
|
});
|
|
const vaultBytes = await packVault(keychain, serverPassword, options);
|
|
const { publicKeyBytes, publicKeys } = buildPublicKeyFile(
|
|
keychain,
|
|
getSlot(rootKeys.keychain, "sign").secretKey
|
|
);
|
|
return { keychain, vaultBytes, publicKeyBytes, publicKeys, version };
|
|
}
|
|
|
|
/**
|
|
* Derive a server keychain deterministically from `rootKeys.keychain.master`
|
|
* at the supplied `version` (default `1`), encrypt under `serverPassword`,
|
|
* and produce a public-key file signed by the *root* keychain's sign slot.
|
|
*/
|
|
export async function createServerKeys(
|
|
rootKeys: RootKeysResult,
|
|
serverPassword: string,
|
|
options?: CreateServerKeysOptions
|
|
): Promise<ServerKeysResult> {
|
|
const version = options?.version ?? 1;
|
|
assertNonNegativeInteger("version", version);
|
|
return buildServerResult(rootKeys, serverPassword, version, options);
|
|
}
|
|
|
|
/**
|
|
* Roll a server keychain to `currentVersion + 1`. Rejects negative
|
|
* `currentVersion` values with `CRYPTOVAULT.ORCHESTRATION.INVALID_VERSION`.
|
|
*/
|
|
export async function rollServerKeys(
|
|
rootKeys: RootKeysResult,
|
|
currentVersion: number,
|
|
serverPassword: string,
|
|
options?: RollServerKeysOptions
|
|
): Promise<ServerKeysResult> {
|
|
assertNonNegativeInteger("currentVersion", currentVersion);
|
|
return buildServerResult(
|
|
rootKeys,
|
|
serverPassword,
|
|
currentVersion + 1,
|
|
options
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Decrypt `vaultBytes` (produced by {@link packVault} or any of the
|
|
* `create*Keys` helpers) and decode the embedded keychain.
|
|
*/
|
|
export async function openVault(
|
|
vaultBytes: Uint8Array,
|
|
password: string
|
|
): Promise<Keychain<LayoutV1Fields>> {
|
|
const plaintext = await openVaultBytes(vaultBytes, password);
|
|
return decodeKeychain<LayoutV1Fields>(plaintext);
|
|
}
|
|
|
|
/**
|
|
* Inverse of {@link openVault}: encrypt an existing `Keychain` under
|
|
* `password` and return the wire bytes.
|
|
*/
|
|
export async function packVault(
|
|
keychain: Keychain<LayoutV1Fields>,
|
|
password: string,
|
|
options?: PackVaultOptions
|
|
): Promise<Uint8Array> {
|
|
const encoded = encodeKeychain(keychain);
|
|
return sealVault(encoded, password, getArgon2idParams(options));
|
|
}
|
|
|
|
/**
|
|
* Decode a signed public-key file. When `optionalSignKey` is supplied the
|
|
* trailing 64-byte signature is verified against it; otherwise the file is
|
|
* decoded without verification (caller has another out-of-band path to trust).
|
|
*/
|
|
export async function viewPublicKeys(
|
|
publicKeyBytes: Uint8Array,
|
|
optionalSignKey?: Uint8Array
|
|
): Promise<PublicKeychain<LayoutV1Fields>> {
|
|
if (publicKeyBytes.length <= SIGNATURE_BYTES) {
|
|
throw new CryptoVaultError(
|
|
CRYPTO_VAULT_CODES["ORCHESTRATION.PUBLIC_KEY_SIGNATURE_INVALID"],
|
|
"error.crypto-vault.orchestration.public-key-signature-invalid",
|
|
{
|
|
context: {
|
|
reason: "short-input",
|
|
length: publicKeyBytes.length,
|
|
},
|
|
}
|
|
);
|
|
}
|
|
const data = publicKeyBytes.slice(0, -SIGNATURE_BYTES);
|
|
if (optionalSignKey !== undefined) {
|
|
const signature = publicKeyBytes.slice(-SIGNATURE_BYTES);
|
|
const ok = ed25519.verify(signature, data, optionalSignKey);
|
|
if (!ok) {
|
|
throw new CryptoVaultError(
|
|
CRYPTO_VAULT_CODES[
|
|
"ORCHESTRATION.PUBLIC_KEY_SIGNATURE_INVALID"
|
|
],
|
|
"error.crypto-vault.orchestration.public-key-signature-invalid",
|
|
{ context: { reason: "verify-failed" } }
|
|
);
|
|
}
|
|
}
|
|
return decodePublicKeychain<LayoutV1Fields>(data);
|
|
}
|
|
|
|
/** Idempotent convenience wrapper over `storage.set` for vault bytes. */
|
|
export async function saveVaultToStorage(
|
|
storage: AbstractStorage,
|
|
key: string,
|
|
vaultBytes: Uint8Array
|
|
): Promise<void> {
|
|
await storage.set(key, vaultBytes);
|
|
}
|
|
|
|
/** Idempotent convenience wrapper over `storage.set` for public-key files. */
|
|
export async function savePublicKeysToStorage(
|
|
storage: AbstractStorage,
|
|
key: string,
|
|
publicKeyBytes: Uint8Array
|
|
): Promise<void> {
|
|
await storage.set(key, publicKeyBytes);
|
|
}
|
|
|
|
/**
|
|
* Fetch bytes from `storage`, translating any "missing key" outcome into
|
|
* `CryptoVaultError(CRYPTOVAULT.ORCHESTRATION.STORAGE_KEY_NOT_FOUND)`.
|
|
*/
|
|
export async function getDataFromStorage(
|
|
storage: AbstractStorage,
|
|
key: string
|
|
): Promise<Uint8Array> {
|
|
const value = await storage.get(key).catch(() => undefined);
|
|
if (value === undefined) {
|
|
throw new CryptoVaultError(
|
|
CRYPTO_VAULT_CODES["ORCHESTRATION.STORAGE_KEY_NOT_FOUND"],
|
|
"error.crypto-vault.orchestration.storage-key-not-found",
|
|
{ context: { key } }
|
|
);
|
|
}
|
|
return value;
|
|
}
|