Skip to content

Commit

Permalink
Implement Node.js spkac APIs
Browse files Browse the repository at this point in the history
  • Loading branch information
jasnell committed Jun 27, 2024
1 parent 900909e commit d92ac53
Show file tree
Hide file tree
Showing 11 changed files with 338 additions and 3 deletions.
6 changes: 5 additions & 1 deletion src/node/crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ import {
createSecretKey,
} from 'node-internal:crypto_keys';

import { Certificate } from 'node-internal:crypto_spkac';

export {
// DH
DiffieHellman,
Expand Down Expand Up @@ -126,6 +128,8 @@ export {
createPrivateKey,
createPublicKey,
createSecretKey,
// Spkac
Certificate,
}

export function getCiphers() {
Expand Down Expand Up @@ -234,7 +238,7 @@ export default {
};

// Classes
// * [ ] crypto.Certificate
// * [x] crypto.Certificate
// * [ ] crypto.Cipher
// * [ ] crypto.Decipher
// * [x] crypto.DiffieHellman
Expand Down
5 changes: 5 additions & 0 deletions src/node/internal/crypto.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ export function createSecretKey(key: ArrayBuffer | ArrayBufferView): CryptoKey;
export function createPrivateKey(key: InnerCreateAsymmetricKeyOptions): CryptoKey;
export function createPublicKey(key: InnerCreateAsymmetricKeyOptions): CryptoKey;

// Spkac
export function verifySpkac(input: ArrayBufferView|ArrayBuffer): boolean;
export function exportPublicKey(input: ArrayBufferView|ArrayBuffer): null | ArrayBuffer;
export function exportChallenge(input: ArrayBufferView|ArrayBuffer): null | ArrayBuffer;

export type KeyData = string | ArrayBuffer | ArrayBufferView;

export interface RsaKeyAlgorithm {
Expand Down
74 changes: 74 additions & 0 deletions src/node/internal/crypto_spkac.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Copyright (c) 2017-2022 Cloudflare, Inc.
// Licensed under the Apache 2.0 license found in the LICENSE file or at:
// https://opensource.org/licenses/Apache-2.0
//
// Copyright Joyent, Inc. and other Node contributors.
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the
// "Software"), to deal in the Software without restriction, including
// without limitation the rights to use, copy, modify, merge, publish,
// distribute, sublicense, and/or sell copies of the Software, and to permit
// persons to whom the Software is furnished to do so, subject to the
// following conditions:
//
// The above copyright notice and this permission notice shall be included
// in all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
// USE OR OTHER DEALINGS IN THE SOFTWARE.

/* todo: the following is adopted code, enabling linting one day */
/* eslint-disable */

import { default as cryptoImpl } from 'node-internal:crypto';
import {
StringLike,
Buffer,
} from 'node-internal:internal_buffer';

import {
getArrayBufferOrView,
} from 'node-internal:crypto_util';

type ArrayLike = cryptoImpl.ArrayLike;

export function verifySpkac(spkac: StringLike | ArrayLike, encoding?: string) {
return cryptoImpl.verifySpkac(getArrayBufferOrView(spkac as any, 'spkac', encoding));
}

export function exportPublicKey(spkac: StringLike | ArrayLike, encoding?: string) {
const ret = cryptoImpl.exportPublicKey(getArrayBufferOrView(spkac as any, 'spkac', encoding));
return ret ? Buffer.from(ret) : Buffer.alloc(0);
}

export function exportChallenge(spkac: StringLike | ArrayLike, encoding?: string) {
const ret = cryptoImpl.exportChallenge(getArrayBufferOrView(spkac as any, 'spkac', encoding));
return ret ? Buffer.from(ret) : Buffer.alloc(0);
}

// The legacy implementation of this exposed the Certificate
// object and required that users create an instance before
// calling the member methods. This API pattern has been
// deprecated, however, as the method implementations do not
// rely on any object state.

// For backwards compatibility reasons, this cannot be converted into a
// ES6 Class.
export function Certificate(this: any) {
if (!(this instanceof Certificate))
return new (Certificate as any)();
}

Certificate.prototype.verifySpkac = verifySpkac;
Certificate.prototype.exportPublicKey = exportPublicKey;
Certificate.prototype.exportChallenge = exportChallenge;

Certificate.exportChallenge = exportChallenge;
Certificate.exportPublicKey = exportPublicKey;
Certificate.verifySpkac = verifySpkac;
2 changes: 1 addition & 1 deletion src/node/internal/internal_buffer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ function createBuffer(length: number) : Buffer {
}

type WithImplicitCoercion<T> = | T | { valueOf(): T; };
type StringLike = WithImplicitCoercion<string> | { [Symbol.toPrimitive](hint: "string"): string; };
export type StringLike = WithImplicitCoercion<string> | { [Symbol.toPrimitive](hint: "string"): string; };
type ArrayBufferLike = WithImplicitCoercion<ArrayBuffer| SharedArrayBuffer>;
type BufferSource = StringLike|ArrayBufferLike|Uint8Array|ReadonlyArray<number>;

Expand Down
1 change: 0 additions & 1 deletion src/node/internal/internal_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,4 +208,3 @@ export function createDeferredPromise() {
reject,
};
}

95 changes: 95 additions & 0 deletions src/workerd/api/crypto/spkac.c++
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
#include "spkac.h"
#include "impl.h"
#include <workerd/jsg/jsg.h>
#include <openssl/x509.h>
#include <openssl/pem.h>

namespace workerd::api {
namespace {
kj::ArrayPtr<const kj::byte> trim(kj::ArrayPtr<const kj::byte> input) {
size_t length = input.size();
for (auto i = length - 1; i >= 0; --i) {
if (input[i] != ' ' && input[i] != '\n' && input[i] != '\r' && input[i] != '\t') {
break;
}
}
return input.first(length);
}

kj::Array<kj::byte> toArray(BIO* bio) {
BUF_MEM* bptr;
BIO_get_mem_ptr(bio, &bptr);
auto buf = kj::heapArray<char>(bptr->length);
auto aptr = kj::arrayPtr(bptr->data, bptr->length);
buf.asPtr().copyFrom(aptr);
return buf.releaseAsBytes();
}

kj::Maybe<kj::Own<NETSCAPE_SPKI>> tryGetSpki(kj::ArrayPtr<const kj::byte> input) {
static constexpr int32_t kMaxLength = kj::maxValue;
JSG_REQUIRE(input.size() <= kMaxLength, RangeError, "spkac is too large");
input = trim(input);
auto ptr = NETSCAPE_SPKI_b64_decode(input.asChars().begin(), input.size());
if (!ptr) return kj::none;
return kj::disposeWith<NETSCAPE_SPKI_free>(ptr);
}

kj::Maybe<kj::Own<EVP_PKEY>> tryOwnPkey(kj::Own<NETSCAPE_SPKI>& spki) {
auto pkey = NETSCAPE_SPKI_get_pubkey(spki.get());
if (!pkey) return kj::none;
return kj::disposeWith<EVP_PKEY_free>(pkey);
}

kj::Maybe<kj::Own<BIO>> tryNewBio() {
auto bioptr = BIO_new(BIO_s_mem());
if (!bioptr) return kj::none;
return kj::disposeWith<BIO_free_all>(bioptr);
}
} // namespace

bool verifySpkac(kj::ArrayPtr<const kj::byte> input) {
// So, this is fun. SPKAC uses MD5 as the digest algorithm. This is a problem because
// using MD5 for signature verification is not allowed in FIPS mode, which means that
// although we have a working implementation here, the result of this call is always
// going to false even if the input signature is correct. So this is a bit of a dead
// end that isn't going to be super useful. Fortunately but the exportPublicKey and
// exportChallenge functions both work correctly and are useful. Unfortunately, this
// likely means users would need to implement their own verification, which sucks.
//
// Alternatively we could choose to implement our own version of the validation that
// bypasses BoringSSL's FIPS configuration. For now tho, this does end up matching
// Node.js' behavior when FIPS is enabled so I guess that's something.
KJ_IF_SOME(spki, tryGetSpki(input)) {
KJ_IF_SOME(key, tryOwnPkey(spki)) {
return NETSCAPE_SPKI_verify(spki.get(), key.get()) > 0;
}
}
return false;
}

kj::Maybe<kj::Array<kj::byte>> exportPublicKey(kj::ArrayPtr<const kj::byte> input) {
KJ_IF_SOME(spki, tryGetSpki(input)) {
KJ_IF_SOME(bio, tryNewBio()) {
KJ_IF_SOME(key, tryOwnPkey(spki)) {
if (PEM_write_bio_PUBKEY(bio.get(), key.get()) > 0) {
return toArray(bio.get());
}
}
}
}
return kj::none;
}

kj::Maybe<kj::Array<kj::byte>> exportChallenge(kj::ArrayPtr<const kj::byte> input) {
KJ_IF_SOME(spki, tryGetSpki(input)) {
kj::byte* buf = nullptr;
int buf_size = ASN1_STRING_to_UTF8(&buf, spki->spkac->challenge);
if (buf_size < 0 || buf == nullptr) return kj::none;
// Pay attention to how the buffer is freed below...
return kj::arrayPtr(buf, buf_size).attach(kj::defer([buf]() {
OPENSSL_free(buf);
}));
}
return kj::none;
}
}
13 changes: 13 additions & 0 deletions src/workerd/api/crypto/spkac.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#pragma once

#include <kj/common.h>

namespace workerd::api {

bool verifySpkac(kj::ArrayPtr<const kj::byte> input);

kj::Maybe<kj::Array<kj::byte>> exportPublicKey(kj::ArrayPtr<const kj::byte> input);

kj::Maybe<kj::Array<kj::byte>> exportChallenge(kj::ArrayPtr<const kj::byte> input);

}
14 changes: 14 additions & 0 deletions src/workerd/api/node/crypto.c++
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
#include <workerd/api/crypto/impl.h>
#include <workerd/api/crypto/kdf.h>
#include <workerd/jsg/jsg.h>
#include <workerd/api/crypto/spkac.h>

namespace workerd::api::node {

Expand Down Expand Up @@ -84,4 +85,17 @@ kj::Array<kj::byte> CryptoImpl::getScrypt(jsg::Lock& js,
return JSG_REQUIRE_NONNULL(scrypt(keylen, N, r, p, maxmem, password, salt),
Error, "Scrypt failed");
}

bool CryptoImpl::verifySpkac(kj::Array<const kj::byte> input) {
return workerd::api::verifySpkac(input);
}

kj::Maybe<kj::Array<kj::byte>> CryptoImpl::exportPublicKey(kj::Array<const kj::byte> input) {
return workerd::api::exportPublicKey(input);
}

kj::Maybe<kj::Array<kj::byte>> CryptoImpl::exportChallenge(kj::Array<const kj::byte> input) {
return workerd::api::exportChallenge(input);
}

} // namespace workerd::api::node
8 changes: 8 additions & 0 deletions src/workerd/api/node/crypto.h
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,10 @@ class CryptoImpl final: public jsg::Object {
jsg::Ref<CryptoKey> createPrivateKey(jsg::Lock& js, CreateAsymmetricKeyOptions options);
jsg::Ref<CryptoKey> createPublicKey(jsg::Lock& js, CreateAsymmetricKeyOptions options);

bool verifySpkac(kj::Array<const kj::byte> input);
kj::Maybe<kj::Array<kj::byte>> exportPublicKey(kj::Array<const kj::byte> input);
kj::Maybe<kj::Array<kj::byte>> exportChallenge(kj::Array<const kj::byte> input);

JSG_RESOURCE_TYPE(CryptoImpl) {
// DH
JSG_NESTED_TYPE(DiffieHellmanHandle);
Expand All @@ -236,6 +240,10 @@ class CryptoImpl final: public jsg::Object {
JSG_METHOD(createSecretKey);
JSG_METHOD(createPrivateKey);
JSG_METHOD(createPublicKey);
// Spkac
JSG_METHOD(verifySpkac);
JSG_METHOD(exportPublicKey);
JSG_METHOD(exportChallenge);
}
};

Expand Down
108 changes: 108 additions & 0 deletions src/workerd/api/node/tests/crypto_spkac-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// Copyright (c) 2017-2022 Cloudflare, Inc.
// Licensed under the Apache 2.0 license found in the LICENSE file or at:
// https://opensource.org/licenses/Apache-2.0
//
// Adapted from Node.js. Copyright Joyent, Inc. and other Node contributors.
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the
// "Software"), to deal in the Software without restriction, including
// without limitation the rights to use, copy, modify, merge, publish,
// distribute, sublicense, and/or sell copies of the Software, and to permit
// persons to whom the Software is furnished to do so, subject to the
// following conditions:
//
// The above copyright notice and this permission notice shall be included
// in all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
// USE OR OTHER DEALINGS IN THE SOFTWARE.

import {
Buffer,
} from 'node:buffer';

import * as assert from 'node:assert';
import { Certificate } from 'node:crypto';

const valid = Buffer.from("MIICUzCCATswggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC33FiIiiexwLe/P8DZx5HsqFlmUO7/lvJ7necJVNwqdZ3ax5jpQB0p6uxfqeOvzcN3k5V7UFb/Am+nkSNZMAZhsWzCU2Z4Pjh50QYz3f0Hour7/yIGStOLyYY3hgLK2K8TbhgjQPhdkw9+QtKlpvbL8fLgONAoGrVOFnRQGcr70iFffsm79mgZhKVMgYiHPJqJgGHvCtkGg9zMgS7p63+Q3ZWedtFS2RhMX3uCBy/mH6EOlRCNBbRmA4xxNzyf5GQaki3T+Iz9tOMjdPP+CwV2LqEdylmBuik8vrfTb3qIHLKKBAI8lXN26wWtA3kN4L7NP+cbKlCRlqctvhmylLH1AgMBAAEWE3RoaXMtaXMtYS1jaGFsbGVuZ2UwDQYJKoZIhvcNAQEEBQADggEBAIozmeW1kfDfAVwRQKileZGLRGCD7AjdHLYEe16xTBPve8Af1bDOyuWsAm4qQLYA4FAFROiKeGqxCtIErEvm87/09tCfF1My/1Uj+INjAk39DK9J9alLlTsrwSgd1lb3YlXY7TyitCmh7iXLo4pVhA2chNA3njiMq3CUpSvGbpzrESL2dv97lv590gUD988wkTDVyYsf0T8+X0Kww3AgPWGji+2f2i5/jTfD/s1lK1nqi7ZxFm0pGZoy1MJ51SCEy7Y82ajroI+5786nC02mo9ak7samca4YDZOoxN4d3tax4B/HDF5dqJSm1/31xYLDTfujCM5FkSjRc4m6hnriEkc=");

const invalid = Buffer.from("UzCCATswggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC33FiIiiexwLe/P8DZx5HsqFlmUO7/lvJ7necJVNwqdZ3ax5jpQB0p6uxfqeOvzcN3k5V7UFb/Am+nkSNZMAZhsWzCU2Z4Pjh50QYz3f0Hour7/yIGStOLyYY3hgLK2K8TbhgjQPhdkw9+QtKlpvbL8fLgONAoGrVOFnRQGcr70iFffsm79mgZhKVMgYiHPJqJgGHvCtkGg9zMgS7p63+Q3ZWedtFS2RhMX3uCBy/mH6EOlRCNBbRmA4xxNzyf5GQaki3T+Iz9tOMjdPP+CwV2LqEdylmBuik8vrfTb3qIHLKKBAI8lXN26wWtA3kN4L7NP+cbKlCRlqctvhmylLH1AgMBAAEWE3RoaXMtaXMtYS1jaGFsbGVuZ2UwDQYJKoZIhvcNAQEEBQADggEBAIozmeW1kfDfAVwRQKileZGLRGCD7AjdHLYEe16xTBPve8Af1bDOyuWsAm4qQLYA4FAFROiKeGqxCtIErEvm87/09tCfF1My/1Uj+INjAk39DK9J9alLlTsrwSgd1lb3YlXY7TyitCmh7iXLo4pVhA2chNA3njiMq3CUpSvGbpzrESL2dv97lv590gUD988wkTDVyYsf0T8+X0Kww3AgPWGji+2f2i5/jTfD/s1lK1nqi7ZxFm0pGZoy1MJ51SCEy7Y82ajroI+5786nC02mo9ak7samca4YDZOoxN4d3tax4B/HDF5dqJSm1/31xYLDTfujCM5FkSjRc4m6hnriEkc=");

const key = Buffer.from(`-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt9xYiIonscC3vz/A2ceR
7KhZZlDu/5bye53nCVTcKnWd2seY6UAdKersX6njr83Dd5OVe1BW/wJvp5EjWTAG
YbFswlNmeD44edEGM939B6Lq+/8iBkrTi8mGN4YCytivE24YI0D4XZMPfkLSpab2
y/Hy4DjQKBq1ThZ0UBnK+9IhX37Ju/ZoGYSlTIGIhzyaiYBh7wrZBoPczIEu6et/
kN2VnnbRUtkYTF97ggcv5h+hDpUQjQW0ZgOMcTc8n+RkGpIt0/iM/bTjI3Tz/gsF
di6hHcpZgbopPL630296iByyigQCPJVzdusFrQN5DeC+zT/nGypQkZanLb4ZspSx
9QIDAQAB
-----END PUBLIC KEY-----`);

const challenge = 'this-is-a-challenge';

export const spkac = {
test() {
const buf = new Uint8Array(2 ** 31);
assert.throws(
() => Certificate.verifySpkac(buf), {
name: 'RangeError',
});
assert.throws(
() => Certificate.exportChallenge(buf), {
name: 'RangeError',
});
assert.throws(
() => Certificate.exportPublicKey(buf), {
name: 'RangeError',
});

// We should decide what we want to do here. The fact that we run
// with fips enabled and SPKAC requires using md5 has the digest
// for the signature means that verifySpkac will always fail since
// fips mode disables the ability to use md5 here. This check also
// fails in Node.js but there we have the options of disabling fips.
assert.ok(!Certificate.verifySpkac(valid));
assert.ok(!Certificate.verifySpkac(invalid));

assert.strictEqual(
stripLineEndings(Certificate.exportPublicKey(valid).toString('utf8')),
stripLineEndings(key.toString('utf8'))
);
assert.strictEqual(Certificate.exportPublicKey(invalid).toString('utf8'), '');

assert.strictEqual(
Certificate.exportChallenge(valid).toString('utf8'),
challenge
);
assert.strictEqual(Certificate.exportChallenge(invalid).toString('utf8'), '');

const ab = copyArrayBuffer(key);
assert.ok(!Certificate.verifySpkac(ab));
assert.ok(!Certificate.verifySpkac(new Uint8Array(ab)));
assert.ok(!Certificate.verifySpkac(new DataView(ab)));

assert.ok(Certificate() instanceof Certificate);

const errObj = { code: 'ERR_INVALID_ARG_TYPE' };

[1, {}, [], Infinity, true, undefined, null].forEach((val) => {
assert.throws(() => Certificate.verifySpkac(val), errObj);
assert.throws(() => Certificate.exportPublicKey(val), errObj);
assert.throws(() => Certificate.exportChallenge(val), errObj);
});
}
};

function stripLineEndings(obj) {
return obj.replace(/\n/g, '');
}

function copyArrayBuffer(buf) {
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
}
Loading

0 comments on commit d92ac53

Please sign in to comment.