Skip to content

feat: IKeyOperations, an opaque private-key operations abstraction - #88

Merged
erossignon merged 3 commits into
masterfrom
feat/key-operations
Aug 29, 2026
Merged

erossignon merged 3 commits into
masterfrom
feat/key-operations

Conversation

@erossignon

Copy link
Copy Markdown
Member

What

Introduces IKeyOperations, an opaque private-key operations abstraction: an object that can use a private key (sign, decrypt) without the key ever being obtainable through it. It is the secure-channel/session-side sibling of CaSigner (which stays sign-only and issuance-scoped), shaped after cloud KMS/HSM APIs so the OPC UA application instance key can live in a TPM, HSM, KMS or OS keystore, non-exportable.

Three commits, purely additive, targeting a v5.10.0 minor:

Commit Content
aea088b IKeyOperations interface, AsymmetricSignParams (PKCS#1 v1.5 / PSS, SHA-1 / SHA-256), AsymmetricDecryptParams (PKCS#1 v1.5 / OAEP-SHA1 / OAEP-SHA256), KeyMetadata, isKeyOperations / hasSyncKeyOperations guards, typed PrivateKeyUnavailableError
855d164 LocalKeyOperations (wraps the PrivateKey envelope, full sync fast path), keyOperationsFromPrivateKey, decryptLong / decryptLongSync multi-block helpers
1700ac4 caSignerFromKeyOperations adapter: one HSM integration serves both channel operations and X509 issuance (CSR / self-signed / CRL)

Design decisions

  • Async by contract, optional sync fast path. A remote key is async; the three required methods return promises. LocalKeyOperations also implements signSync / decryptBlockSync / getKeyMetadataSync, so code paths that are synchronous by contract (OPC UA chunk assembly) keep bit-identical behavior with local keys. Callers pick a path via hasSyncKeyOperations.
  • Per-block decrypt. One decryptBlock call is one HSM/KMS operation; decryptLong ships the multi-block loop once (concurrent decryption, ordered reassembly, misaligned buffers rejected) so providers never reimplement it.
  • Metadata declared, not inferred. An HSM-held key exposes nothing to inspect; KeyMetadata travels with the object, same philosophy as CaSigner.algorithm. RSA only today; the EC extension path (namedCurve, ECDSA params variant) is documented as additive.
  • decryptBlock throws on failure, unlike privateDecrypt_native which swallows errors into a 1-byte buffer. A remote provider throws anyway; the swallow-into-garbage anti-oracle behavior belongs to the caller, applied uniformly to local and remote keys. Recorded in the TSDoc.
  • PSS salt length pinned to digest length (RSA_PSS_SALTLEN_DIGEST), the convention the OPC UA Aes256_Sha256_RsaPss profile and WebCrypto verifiers expect.
  • Padding constants come from the legacy node:constants module, not crypto.constants, matching crypto_utils.ts: it is what the node-opcua-crypto-web browser shim knows how to satisfy (a named constants import from node:crypto breaks the web bundle).
  • caSignerFromKeyOperations requires getPublicKey and says so: a CaSigner must produce its public half (CSR embeds it, an issued certificate's AKI derives from it) and an opaque key has no other source for it.

Tests

30 new tests across three files; full suite 36 files, 309 passed, 6 skipped (pre-existing skips), zero behavioral change elsewhere.

  • PKCS#1 v1.5 signatures byte-match node:crypto for SHA-1 and SHA-256; PSS verifies at salt = 32 and fails at salt = 20, proving the convention is pinned
  • Decrypt round-trips for all three paddings; the PKCS#1 v1.5 case is capability-probed and self-skips on Node builds that disable it (CVE-2023-46809)
  • Metadata cross-checks rsaLengthPrivateKey for RSA 2048 / 3072 / 4096; both envelope forms (PEM string, KeyObject) accepted; non-RSA keys rejected with a clear error
  • decryptLong reassembles by position, proven with a mock whose first block resolves last; decryptLongSync refuses async-only providers
  • Adapter-driven createCertificateFromCsr yields a certificate that verifies against the CA key's public half and not against an unrelated key; an async-only provider signs a CSR over its own key with a spy proving all signing goes through IKeyOperations.sign

Context

First step of the "Opaque Private Key Operations (HSM/KMS)" effort tracked on the org project boards. node-opcua-pki's CertificateManager gains a keyOperations option next, then node-opcua adopts the abstraction for session/identity paths and the secure channel.

🤖 Generated with Claude Code

Sign / per-block decrypt / metadata interface shaped after KMS and HSM
APIs, sibling of CaSigner (which stays sign-only and issuance-scoped).
Async by contract, with an optional sync fast path that local-key
implementations provide so synchronous callers keep working unchanged.
Metadata is declared rather than inferred, because an HSM-held key
exposes nothing to inspect. Types, guards and the typed
PrivateKeyUnavailableError only; LocalKeyOperations comes next.
The in-process implementation of IKeyOperations, sync fast path
included, intended as the one place that dereferences the PrivateKey
envelope. decryptLong/decryptLongSync ship the multi-block loop once:
one block per provider call, concurrent decryption, ordered
reassembly, misaligned buffers rejected. Unlike privateDecrypt_native,
decryptBlock throws on failure; the swallow-into-garbage anti-oracle
behavior belongs to callers, uniformly for local and remote keys.
PKCS1v15 signatures byte-match node:crypto; PSS salts at digest
length; metadata cross-checks rsaLengthPrivateKey for 2048/3072/4096.
Exposes an IKeyOperations as a CaSigner so one HSM/KMS integration
serves both the secure-channel operations and X509 issuance. The
concrete case is certificate renewal over an opaque key: a CSR whose
proof-of-possession signature and embedded public key both come from
the provider. Requires getPublicKey and says so; RSASSA-PKCS1-v1_5
with SHA-256 only, the hash parameter left in place so widening stays
additive.
@erossignon
erossignon merged commit f3696c5 into master Aug 29, 2026
20 checks passed
@erossignon
erossignon deleted the feat/key-operations branch August 29, 2026 10:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant