com.joyent.http.signature.Signer Maven / Gradle / Ivy
/*
* Copyright (c) 2013-2017, Joyent, Inc. All rights reserved.
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package com.joyent.http.signature;
import com.joyent.http.signature.crypto.NativeRSAProvider;
import org.bouncycastle.util.encoders.Base64;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.security.InvalidKeyException;
import java.security.KeyPair;
import java.security.NoSuchAlgorithmException;
import java.security.Provider;
import java.security.Signature;
import java.security.SignatureException;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.Date;
import java.util.Locale;
import java.util.Objects;
/**
* HTTP authorization signer. This adheres to the specs of the node-http-signature spec.
*
* @see Signing HTTP Messages
* @see Original Version
* @author Yunong Xiao
* @author Elijah Zupancic
* @since 1.0.0
*/
public class Signer {
/**
* The format for the http date header.
*
* @deprecated In java8 and later a RFC appropriate format is
* defined in the standard library using modern classes.
*/
@Deprecated
@SuppressWarnings("DateFormatConstant")
public static final DateFormat DATE_FORMAT = new SimpleDateFormat(
"EEE MMM d HH:mm:ss yyyy zzz", Locale.ENGLISH);
/**
* The template for the Authorization header.
*/
private static final String AUTHZ_HEADER =
"Signature keyId=\"/%s/keys/%s\",algorithm=\"%s\",signature=\"%s\"";
/**
* The template for the authorization signing signing string.
*/
private static final String AUTHZ_SIGNING_STRING = "date: %s";
/**
* The prefix for the signature component of the authorization header.
*/
private static final String AUTHZ_PATTERN = "signature=\"";
/**
* Cryptographic signature used for signing requests.
*/
private final Signature signature;
/**
* Private field with the computed http header algorithm.
*/
private final String httpHeaderAlgorithm;
/**
* Creates a new instance of the class and enables native code acceleration of
* cryptographic signing by default.
*
* @deprecated Prefer use of {@link Signer.Builder}
*/
@Deprecated
public Signer() {
this(true);
}
/**
* Creates a new instance of the class.
*
* @param useNativeCodeToSign true to enable native code acceleration of cryptographic singing
*
* @deprecated Prefer use of {@link Signer.Builder}
*/
@Deprecated
@SuppressWarnings("checkstyle:avoidinlineconditionals")
public Signer(final boolean useNativeCodeToSign) {
this(new Builder("RSA").providerCode(useNativeCodeToSign ? "native.jnagmp" : "stdlib"));
}
/**
* {@link Signer.Builder} This is public (a difference from the
* normal Builder pattern) for use by {@link ThreadLocalSigner}.
*
* @param builder {@link Signer.Builder}
*/
public Signer(final Builder builder) {
Provider provider = builder.algHelper.makeProvider(builder.providerCode);
httpHeaderAlgorithm = builder.httpHeaderAlgorithm();
if (provider == null) {
try {
signature = Signature.getInstance(builder.javaStandardName(null));
} catch (NoSuchAlgorithmException nsae) {
throw new CryptoException(nsae);
}
} else {
try {
signature = Signature.getInstance(builder.javaStandardName(provider), provider);
} catch (NoSuchAlgorithmException nsae) {
throw new CryptoException(nsae);
}
}
}
/**
* @see KeyPairLoader#getKeyPair
*
* @param keyPath The path to the key
* @return public-private keypair object
* @throws IOException If unable to read the private key from the file
*
* @deprecated Since a {@code KeyPair} is needed to instantiate,
* is is now backwards for this to be an instance method.
*/
@Deprecated
public KeyPair getKeyPair(final Path keyPath) throws IOException {
return KeyPairLoader.getKeyPair(keyPath);
}
/**
* @see KeyPairLoader#getKeyPair
*
* @param privateKeyContent private key content as a string
* @param password password associated with key
* @return public-private keypair object
* @throws IOException If unable to read the private key from the string
*
* @deprecated Since a {@code KeyPair} is needed to instantiate,
* is is now backwards for this to be an instance method.
*/
@Deprecated
public KeyPair getKeyPair(final String privateKeyContent, final char[] password) throws IOException {
return KeyPairLoader.getKeyPair(privateKeyContent, password);
}
/**
* @see KeyPairLoader#getKeyPair
*
* @param pKeyBytes private key content as a byte array
* @param password password associated with key
* @return public-private keypair object
* @throws IOException If unable to read the private key from the string
*
* @deprecated Since a {@code KeyPair} is needed to instantiate,
* is is now backwards for this to be an instance method.
*/
@Deprecated
public KeyPair getKeyPair(final byte[] pKeyBytes, final char[] password) throws IOException {
return KeyPairLoader.getKeyPair(pKeyBytes, password);
}
/**
* @see KeyPairLoader#getKeyPair
*
* @param is private key content as a stream
* @param password password associated with key
* @return public/private keypair object
* @throws IOException If unable to read the private key from the string
*
* @deprecated Since a {@code KeyPair} is needed to instantiate,
* is is now backwards for this to be an instance method.
*/
@Deprecated
public KeyPair getKeyPair(final InputStream is,
final char[] password) throws IOException {
return KeyPairLoader.getKeyPair(is, password);
}
/**
* Generate a signature for an authorization HTTP header using the
* current time as a timestamp.
*
* @param login Account/login name
* @param fingerprint key fingerprint (ignored)
* @param keyPair public/private keypair
* @return value to Authorization header
*
* @deprecated The fingerprint is now calculated from the given key.
*/
@Deprecated
public String createAuthorizationHeader(final String login,
final String fingerprint,
final KeyPair keyPair) {
return createAuthorizationHeader(login, keyPair, defaultSignDateAsString());
}
/**
* Generate a signature for an authorization HTTP header using the
* current time as a timestamp.
*
* @param login Account/login name
* @param keyPair public/private keypair
* @return value to Authorization header
*/
@SuppressWarnings("unused")
public String createAuthorizationHeader(final String login,
final KeyPair keyPair) {
return createAuthorizationHeader(login, keyPair, defaultSignDateAsString());
}
/**
* Generate a signature for an authorization HTTP header.
*
* @param login Account/login name
* @param fingerprint key fingerprint (ignored)
* @param keyPair public/private keypair
* @param date Date to be converted to a RFC 822 compliant string
* @return value to Authorization header
*
* @deprecated The fingerprint is now calculated from the given key.
*/
@Deprecated
public String createAuthorizationHeader(final String login,
final String fingerprint,
final KeyPair keyPair,
final Date date) {
return createAuthorizationHeader(login, keyPair, date);
}
/**
* Generate a signature for an authorization HTTP header.
*
* @param login Account/login name
* @param keyPair public/private keypair
* @param date DateTime to be converted to a RFC 822 compliant string
* @return value to Authorization header
*
* @deprecated Prefer ZonedDateTime to java.util.Date
*/
@Deprecated
public String createAuthorizationHeader(final String login,
final KeyPair keyPair,
final Date date) {
final ZonedDateTime zdt;
if (date == null) {
zdt = null;
} else {
zdt = ZonedDateTime.ofInstant(date.toInstant(), ZoneOffset.UTC);
}
return createAuthorizationHeader(login, keyPair, zdt);
}
/**
* Generate a signature for an authorization HTTP header.
*
* @param login Account/login name
* @param keyPair public/private keypair
* @param dateTime DateTime to be converted to a RFC 822 compliant string
* @return value to Authorization header
*/
public String createAuthorizationHeader(final String login,
final KeyPair keyPair,
final ZonedDateTime dateTime) {
final String stringDate;
if (dateTime == null) {
stringDate = defaultSignDateAsString();
} else {
stringDate = DateTimeFormatter.RFC_1123_DATE_TIME.format(dateTime);
}
return createAuthorizationHeader(login, keyPair, stringDate);
}
/**
* Generate a signature for an authorization HTTP header.
*
* @param login Account/login name
* @param fingerprint key fingerprint (ignored)
* @param keyPair public/private keypair
* @param date Date as RFC 822 compliant string
* @return value to Authorization header
*
* @deprecated The fingerprint is now calculated from the given key.
*/
@Deprecated
public String createAuthorizationHeader(final String login,
final String fingerprint,
final KeyPair keyPair,
final String date) {
Objects.requireNonNull(login, "Login must be present");
Objects.requireNonNull(keyPair, "Keypair must be present");
return createAuthorizationHeader(login, keyPair, date);
}
/**
* Generate a signature for an authorization HTTP header.
*
* @param login Account/login name
* @param keyPair public/private keypair
* @param date Date as RFC 822 compliant string
* @return value to Authorization header
*/
public String createAuthorizationHeader(final String login,
final KeyPair keyPair,
final String date) {
Objects.requireNonNull(login, "Login must be present");
Objects.requireNonNull(keyPair, "Keypair must be present");
try {
signature.initSign(keyPair.getPrivate());
final String signingString = String.format(AUTHZ_SIGNING_STRING, date);
signature.update(signingString.getBytes(StandardCharsets.UTF_8));
final byte[] signedDate = signature.sign();
final byte[] encodedSignedDate = Base64.encode(signedDate);
final String fingerprint = KeyFingerprinter.md5Fingerprint(keyPair);
return String.format(AUTHZ_HEADER, login, fingerprint, httpHeaderAlgorithm,
new String(encodedSignedDate, StandardCharsets.US_ASCII));
} catch (final InvalidKeyException e) {
throw new CryptoException("invalid key", e);
} catch (final SignatureException e) {
throw new CryptoException("invalid signature", e);
}
}
/**
* Cryptographically signs an any data input.
*
* @param login Account/login name
* @param fingerprint key fingerprint (ignored)
* @param keyPair public/private keypair
* @param data data to be signed
* @return signed value of data
*
* @deprecated The fingerprint is now calculated from the given key.
*/
@Deprecated
public byte[] sign(final String login,
final String fingerprint,
final KeyPair keyPair,
final byte[] data) {
return sign(login, keyPair, data);
}
/**
* Cryptographically signs an any data input.
*
* @param login Account/login name
* @param keyPair public/private keypair
* @param data data to be signed
* @return signed value of data
*/
public byte[] sign(final String login,
final KeyPair keyPair,
final byte[] data) {
Objects.requireNonNull(login, "Login must be present");
Objects.requireNonNull(keyPair, "Keypair must be present");
Objects.requireNonNull(data, "Data must be present");
try {
signature.initSign(keyPair.getPrivate());
signature.update(data);
return signature.sign();
} catch (final InvalidKeyException e) {
throw new CryptoException("invalid key", e);
} catch (final SignatureException e) {
throw new CryptoException("invalid signature", e);
}
}
/**
* Cryptographically signs an any data input.
*
* @param login Account/login name
* @param fingerprint key fingerprint (ignored)
* @param keyPair public/private keypair
* @param data data that was signed
* @param signedData data to verify against signature
* @return signed value of data
*
* @deprecated The fingerprint is now calculated from the given key.
*/
@Deprecated
public boolean verify(final String login,
final String fingerprint,
final KeyPair keyPair,
final byte[] data,
final byte[] signedData) {
return verify(login, keyPair, data, signedData);
}
/**
* Cryptographically signs an any data input.
*
* @param login Account/login name
* @param keyPair public/private keypair
* @param data data that was signed
* @param signedData data to verify against signature
* @return signed value of data
*/
public boolean verify(final String login,
final KeyPair keyPair,
final byte[] data,
final byte[] signedData) {
Objects.requireNonNull(login, "Login must be present");
Objects.requireNonNull(keyPair, "Keypair must be present");
Objects.requireNonNull(signedData, "Data must be present");
try {
signature.initVerify(keyPair.getPublic());
signature.update(data);
return signature.verify(signedData);
} catch (final InvalidKeyException e) {
throw new CryptoException("invalid key", e);
} catch (final SignatureException e) {
throw new CryptoException("invalid signature", e);
}
}
/**
* The current timestamp in UTC as a RFC 822 compliant string.
* @return Date as RFC 822 compliant string
*/
public String defaultSignDateAsString() {
return DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now(ZoneOffset.UTC));
}
/**
* Verify a signed HTTP Authorization header.
*
* @param keyPair public/private keypair
* @param authzHeader authorization header value
* @param date Date as RFC 822 compliant string
* @return True if the request is valid, false if not.
* @throws CryptoException If unable to verify the request.
*/
public boolean verifyAuthorizationHeader(final KeyPair keyPair,
final String authzHeader,
final String date) {
Objects.requireNonNull(keyPair, "Keypair must be present");
Objects.requireNonNull(authzHeader, "AuthzHeader must be present");
Objects.requireNonNull(date, "Date must be present");
String myDate = String.format(AUTHZ_SIGNING_STRING, date);
try {
signature.initVerify(keyPair.getPublic());
final int startIndex = authzHeader.indexOf(AUTHZ_PATTERN);
if (startIndex == -1) {
throw new CryptoException(
String.format("invalid authorization header %s", authzHeader));
}
final String encodedSignedDate = authzHeader.substring(startIndex + AUTHZ_PATTERN.length(),
authzHeader.length() - 1);
final byte[] signedDate = Base64.decode(encodedSignedDate.getBytes(StandardCharsets.UTF_8));
signature.update(myDate.getBytes(StandardCharsets.UTF_8));
return signature.verify(signedDate);
} catch (final InvalidKeyException e) {
throw new CryptoException("invalid key", e);
} catch (final SignatureException e) {
throw new CryptoException("invalid signature", e);
}
}
/**
* Return a string representation of the full algorithm. For
* example: "rsa-sha256"
*
* @return Algorithm name.
*/
public String getHttpHeaderAlgorithm() {
return httpHeaderAlgorithm;
}
/**
* This method is visible for tests or benchmarks.
*
* @return instance of the signature cipher implementation
*/
Signature getSignature() {
return signature;
}
@Override
public String toString() {
final StringBuilder sb = new StringBuilder("Signer{");
sb.append("signature=").append(signature);
sb.append(",provider=").append(signature.getProvider().getName());
sb.append(",httpHeaderAlgorithm=").append(httpHeaderAlgorithm);
sb.append('}');
return sb.toString();
}
/**
* Builder class for {@link Signer}.
*
* The signing algorithm can be identified by a string (using the
* same names as {@link java.security.PrivateKey#getAlgorithm}),
* or by just passing in a {@link java.security.KeyPair}. The
* supported singing algorithms are RSA, DSA, and ECDSA.
*
* Signers can be further configured by specifying a string
* representation of a hashing algorithms. For example, {@code
* SHA512} instead of {@code SHA256}. The default is {@code
* SHA256} in for all cases. The supported hash names are:
*
*
* - RSA: {@code SHA1}, {@code SHA256}, {@code SHA512}
* - DSA: {@code SHA1}, {@code SHA256}
* - ECDSA: {@code SHA256}, {@code SHA384}, {@code SHA512}
*
*
* {@code providerCode} is designate and alternative provider to
* the standard library. Currently the only algorithm that
* supports a custom provider is {@code RSA} with {@code
* native.jnagmp}. This is the default. See {@link
* com.joyent.http.signature.crypto.NativeRSAWithSHA} for more
* information. All singing algorithms support {@code stdlib} to
* use the standard library.
*/
@SuppressWarnings("checkstyle:javadocvariable")
public static class Builder {
private final SigningAlgorithmHelper algHelper;
private String hash;
private String providerCode;
/**
* Instantiate a new Builder based on the algorithm of the
* given keypair.
*
* @param keyPair The given KeyPair.
*/
public Builder(final KeyPair keyPair) {
this.algHelper = SigningAlgorithmHelper.create(keyPair);
hash = algHelper.defaultHash();
providerCode = algHelper.defaultProviderCode();
}
/**
* Instantiate a new Builder based on the explicitly given
* algorithm.
*
* @param algorithm {@link java.security.PrivateKey#getAlgorithm}
*/
public Builder(final String algorithm) {
this.algHelper = SigningAlgorithmHelper.create(algorithm);
hash = algHelper.defaultHash();
providerCode = algHelper.defaultProviderCode();
}
/**
* Overrides the default hash type.
*
* @param hash New hash type
* @return This {@code Builder} object
*/
@SuppressWarnings("checkstyle:hiddenfield")
public Builder hash(final String hash) {
algHelper.checkSupportedHash(hash);
this.hash = hash;
return this;
}
/**
* Overrides the default provider code.
*
* @param providerCode New provider code
* @return This {@code Builder} object
*/
@SuppressWarnings("checkstyle:hiddenfield")
public Builder providerCode(final String providerCode) {
algHelper.checkSupportedProviderCode(providerCode);
this.providerCode = providerCode;
return this;
}
/**
* From the configured singing algorithm and hash, return a
* string representation as used by the @see Java
* Cryptography Architecture Standard Algorithm Name
* Documentation.
*
* @param provider Provider used for signing.
* @return The standard representation
*/
private String javaStandardName(final Provider provider) {
return hash + "with" + algHelper.providerPrefix(provider) + algHelper.getAlgorithm();
}
/**
* From the configured signing algorithm and hash, return the
* representation formatted for the HTTP Signature field.
*
* @return The header string
*/
private String httpHeaderAlgorithm() {
return algHelper.getAlgorithm().toLowerCase() + "-" + hash.toLowerCase();
}
/**
* Returns a newly-created {@code Signer} based on the contents of the
* {@code Builder}.
*
* @return The new {@code Builder}
*/
public Signer build() {
return new Signer(this);
}
/**
* Helper class with per algorithm configuration.
*/
private abstract static class SigningAlgorithmHelper {
/**
* Create a new {@code SigningAlgorithmHelper} based on
* the given {@code KeyPair}.
*
* @param keyPair {@code} KeyPair to sign for
* @return New {@code SigningAlgorithmHelper} instance.
*/
public static SigningAlgorithmHelper create(final KeyPair keyPair) {
return create(keyPair.getPrivate().getAlgorithm());
}
/**
* Create a new {@code SigningAlgorithmHelper} based on
* the given algorithm code.
* @param algorithm {@see java.security.KeyPair#getAlgorithm}
* @return New {@code SigningAlgorithmHelper} instance.
*/
public static SigningAlgorithmHelper create(final String algorithm) {
switch (algorithm) {
case "RSA":
return new RsaHelper();
case "DSA":
return new DsaHelper();
// See NssBridgeKeyConverter on the two names
case "ECDSA":
case "EC":
return new EcdsaHelper();
default:
throw new IllegalArgumentException("invalid signing algorithm: " + algorithm);
}
}
/**
* Return the string code for the instantiated algorithm helper.
*
* @return {@see java.security.KeyPair#getAlgorithm}
*/
public abstract String getAlgorithm();
/**
* Get all of the hash algorithms supported by the
* algorithm, in sorted order.
*
* @return The sorted hash algorihtm names.
*/
public abstract String[] getSupportedHashes();
/**
* Get the default hash name for this signing algorithm.
*
* @return The default hash name.
*/
public abstract String defaultHash();
/**
* Get all of the provider codes supported by the
* algorithm, in sorted order.
*
* @return The sorted provider codes.
*/
public abstract String[] getSupportedProviderCodes();
/**
* Get the default provider code for this signing algorithm.
*
* @return The default provider code
*/
public abstract String defaultProviderCode();
/**
* Throws {@code IllegalArgumentException} if the given
* {@code String} does not match a supported hash algorithm.
*
* @param hash Name to check.
*/
public void checkSupportedHash(final String hash) {
if (Arrays.binarySearch(getSupportedHashes(), hash) == -1) {
throw new IllegalArgumentException("invalid hash algorithm: " + hash);
}
}
/**
* Throws {@code IllegalArgumentException} if the given
* {@code String} does not match a supported provider code.
*
* @param providerCode Name to check.
*/
public void checkSupportedProviderCode(final String providerCode) {
if (Arrays.binarySearch(getSupportedProviderCodes(), providerCode) == -1) {
throw new IllegalArgumentException("invalid providerCode algorithm: " + providerCode);
}
}
/**
* A {@code Provider} outside of the Java standard
* library, might have a special "Algorithm Name". @see
* Signer.Builder#javaStandardName and @see #makeProvider
*
* @param provider The {@code Provider} from @see #makeProvider.
* @return The "Algorithm Name" modification, or the empty string.
*/
public String providerPrefix(final Provider provider) {
return "";
}
/**
* If a special {@link java.security.Provider} is
* requested, construct and return it, otherwise return
* {@code null} to use the Java standard library.
*
* @param providerCode The configured {@code Provider}
* code.
* @return The new {@link java.security.Provider}, or
* {@code null} if using the standard library.
*/
public Provider makeProvider(final String providerCode) {
return null;
}
}
/**
* RSA implementation of {@code SigningAlgorithmHelper}.
*/
@SuppressWarnings({"checkstyle:javadocmethod", "checkstyle:javadoctype"})
private static class RsaHelper extends SigningAlgorithmHelper {
private static final String[] SUPPORTED_HASHES = {"SHA1", "SHA256", "SHA512"};
private static final String[] SUPPORTED_PROVIDER_CODES = {"native.jnagmp", "stdlib"};
/**
* OS names with native support in jnagmp.
* Always keep values sorted because we binary search them.
*/
private static final String[] SUPPORTED_NATIVE_OS =
new String[] {"linux", "mac os x", "sunos"};
/**
* Architectures with native support in jnagmp.
* Always keep values sorted because we binary search them.
*/
private static final String[] SUPPORTED_NATIVE_ARCH =
new String[] {"amd64", "x86_64"};
/**
* When true we are on a platform that supports native libgmp for modpow.
*/
private static final boolean JNAGMP_SUPPORTED;
static {
final String os = System.getProperty("os.name").toLowerCase();
final String arch = System.getProperty("os.arch").toLowerCase();
JNAGMP_SUPPORTED = Arrays.binarySearch(SUPPORTED_NATIVE_OS, os) >= 0
&& Arrays.binarySearch(SUPPORTED_NATIVE_ARCH, arch) >= 0;
System.setProperty("native.jnagmp", Objects.toString(JNAGMP_SUPPORTED));
}
@Override
public String getAlgorithm() {
return "RSA";
}
@Override
public String[] getSupportedHashes() {
return SUPPORTED_HASHES;
}
@Override
public String defaultHash() {
return "SHA256";
}
@Override
public String[] getSupportedProviderCodes() {
return SUPPORTED_PROVIDER_CODES;
}
@Override
public String defaultProviderCode() {
return "native.jnagmp";
}
@Override
public String providerPrefix(final Provider provider) {
if (provider != null) {
return "Native";
} else {
return "";
}
}
@Override
public Provider makeProvider(final String providerCode) {
if (providerCode.equals("native.jnagmp") && JNAGMP_SUPPORTED) {
try {
return new NativeRSAProvider();
// if ANYTHING goes wrong, we default to the JVM implementation of the signing algo
} catch (Exception e) {
e.printStackTrace();
return null;
}
} else {
return null;
}
}
}
/**
* DSA implementation {@code SigningAlgorithmHelper}.
*/
@SuppressWarnings({"checkstyle:javadocmethod", "checkstyle:javadoctype"})
private static class DsaHelper extends SigningAlgorithmHelper {
private static final String[] SUPPORTED_HASHES = {"SHA1", "SHA256"};
private static final String[] SUPPORTED_PROVIDER_CODES = {"stdlib"};
@Override
public String getAlgorithm() {
return "DSA";
}
@Override
public String[] getSupportedHashes() {
return SUPPORTED_HASHES;
}
@Override
public String defaultHash() {
return "SHA256";
}
@Override
public String[] getSupportedProviderCodes() {
return SUPPORTED_PROVIDER_CODES;
}
@Override
public String defaultProviderCode() {
return "stdlib";
}
}
/**
* ECDSA implementation {@code SigningAlgorithmHelper}.
*/
@SuppressWarnings({"checkstyle:javadocmethod", "checkstyle:javadoctype"})
private static class EcdsaHelper extends SigningAlgorithmHelper {
private static final String[] SUPPORTED_HASHES = {"SHA256", "SHA384", "SHA512"};
private static final String[] SUPPORTED_PROVIDER_CODES = {"stdlib"};
@Override
public String getAlgorithm() {
return "ECDSA";
}
@Override
public String[] getSupportedHashes() {
return SUPPORTED_HASHES;
}
@Override
public String defaultHash() {
return "SHA256";
}
@Override
public String[] getSupportedProviderCodes() {
return SUPPORTED_PROVIDER_CODES;
}
@Override
public String defaultProviderCode() {
return "stdlib";
}
}
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy