-
Notifications
You must be signed in to change notification settings - Fork 1
Feat/verified identities #102
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,196 @@ | ||
| package com.godaddy.ans.sdk.crypto; | ||
|
|
||
| import com.nimbusds.jose.JOSEException; | ||
| import com.nimbusds.jose.JWSAlgorithm; | ||
| import com.nimbusds.jose.JWSHeader; | ||
| import com.nimbusds.jose.JWSSigner; | ||
| import com.nimbusds.jose.crypto.ECDSASigner; | ||
| import com.nimbusds.jose.crypto.RSASSASigner; | ||
| import com.nimbusds.jose.jwk.Curve; | ||
| import com.nimbusds.jose.jwk.ECKey; | ||
| import com.nimbusds.jose.jwk.JWK; | ||
| import com.nimbusds.jose.jwk.OctetKeyPair; | ||
| import com.nimbusds.jose.jwk.RSAKey; | ||
| import com.nimbusds.jose.util.Base64URL; | ||
| import org.slf4j.Logger; | ||
| import org.slf4j.LoggerFactory; | ||
|
|
||
| import java.nio.charset.StandardCharsets; | ||
| import java.security.GeneralSecurityException; | ||
| import java.security.PrivateKey; | ||
| import java.security.PublicKey; | ||
| import java.security.Signature; | ||
| import java.security.interfaces.ECPrivateKey; | ||
| import java.security.interfaces.ECPublicKey; | ||
| import java.security.interfaces.EdECPrivateKey; | ||
| import java.security.interfaces.EdECPublicKey; | ||
| import java.security.interfaces.RSAPrivateKey; | ||
| import java.security.interfaces.RSAPublicKey; | ||
| import java.util.Arrays; | ||
|
|
||
| /** | ||
| * Signs Verified-Identity control-proof challenges as compact JWS strings. | ||
| * | ||
| * <p>The Registration Authority (RA) serves a {@code signingInput} — the base64url of the | ||
| * canonical proof bytes — in the {@code 202} challenge round. This signer produces one compact | ||
| * JWS per proven key, suitable for the {@code signedProofs} array of a verify-control request.</p> | ||
| * | ||
| * <p>The served {@code signingInput} becomes the JWS payload segment <b>verbatim</b>: the RA checks | ||
| * payload equality before it checks the signature, so the client never canonicalizes or re-encodes | ||
| * it. The protected header always carries {@code kid} and may carry the public {@code jwk}.</p> | ||
| * | ||
| * <p>This signer supports only the algorithms the verifier implements: EdDSA (Ed25519), ES256 | ||
| * (ECDSA P-256), and RS256 (RSA >= 2048). It infers the algorithm from the private key. It | ||
| * rejects key-agreement keys (X25519) and curves with no verifier (secp256k1, P-384, P-521) | ||
| * before it signs.</p> | ||
| */ | ||
| public final class IdentityProofSigner { | ||
|
|
||
| private static final Logger LOG = LoggerFactory.getLogger(IdentityProofSigner.class); | ||
|
|
||
| private static final int MIN_RSA_KEY_BITS = 2048; | ||
| private static final int ED25519_RAW_KEY_LEN = 32; | ||
| private static final String ED25519 = "Ed25519"; | ||
|
|
||
| /** | ||
| * Creates a new IdentityProofSigner. | ||
| */ | ||
| public IdentityProofSigner() { | ||
| // Default constructor | ||
| } | ||
|
|
||
| /** | ||
| * Signs the served {@code signingInput} and returns a compact JWS with {@code kid} in the | ||
| * protected header. | ||
| * | ||
| * @param signingInput the base64url signing input served by the RA, used as the JWS payload verbatim | ||
| * @param privateKey the private key that proves control of the identifier | ||
| * @param kid the verification-method id claimed by this proof | ||
| * @return the compact JWS ({@code header.payload.signature}) | ||
| * @throws IllegalArgumentException if an argument is missing or the key/algorithm is unsupported | ||
| * @throws RuntimeException if signing fails | ||
| */ | ||
| public String sign(String signingInput, PrivateKey privateKey, String kid) { | ||
| return sign(signingInput, privateKey, kid, null); | ||
| } | ||
|
|
||
| /** | ||
| * Signs the served {@code signingInput} and returns a compact JWS with {@code kid} and the | ||
| * public {@code jwk} in the protected header. | ||
| * | ||
| * <p>The embedded {@code jwk} is public-only. It is required by the quickstart noop resolver and | ||
| * ignored by the web resolver, which always uses the resolved DID document.</p> | ||
| * | ||
| * @param signingInput the base64url signing input served by the RA, used as the JWS payload verbatim | ||
| * @param privateKey the private key that proves control of the identifier | ||
| * @param kid the verification-method id claimed by this proof | ||
| * @param publicKey the public key to embed as {@code jwk}, or {@code null} to omit it | ||
| * @return the compact JWS ({@code header.payload.signature}) | ||
| * @throws IllegalArgumentException if an argument is missing or the key/algorithm is unsupported | ||
| * @throws RuntimeException if signing fails | ||
| */ | ||
| public String sign(String signingInput, PrivateKey privateKey, String kid, PublicKey publicKey) { | ||
| if (signingInput == null || signingInput.isBlank()) { | ||
| throw new IllegalArgumentException("signingInput cannot be null or blank"); | ||
| } | ||
| if (privateKey == null) { | ||
| throw new IllegalArgumentException("privateKey cannot be null"); | ||
| } | ||
| if (kid == null || kid.isBlank()) { | ||
| throw new IllegalArgumentException("kid cannot be null or blank"); | ||
| } | ||
|
|
||
| JWSAlgorithm algorithm = resolveAlgorithm(privateKey); | ||
| LOG.debug("Signing identity proof with algorithm {} and kid {}", algorithm, kid); | ||
|
|
||
| JWSHeader.Builder headerBuilder = new JWSHeader.Builder(algorithm).keyID(kid); | ||
| if (publicKey != null) { | ||
| headerBuilder.jwk(toPublicJwk(algorithm, publicKey)); | ||
| } | ||
| JWSHeader header = headerBuilder.build(); | ||
|
|
||
| String headerSegment = header.toBase64URL().toString(); | ||
| byte[] signingInputBytes = (headerSegment + "." + signingInput).getBytes(StandardCharsets.US_ASCII); | ||
|
|
||
| Base64URL signature = computeSignature(algorithm, privateKey, header, signingInputBytes); | ||
| return headerSegment + "." + signingInput + "." + signature; | ||
| } | ||
|
|
||
| /** | ||
| * Resolves the JWS algorithm from the private key, rejecting unsupported keys before signing. | ||
| */ | ||
| private JWSAlgorithm resolveAlgorithm(PrivateKey privateKey) { | ||
| if (privateKey instanceof RSAPrivateKey rsaKey) { | ||
| int bits = rsaKey.getModulus().bitLength(); | ||
| if (bits < MIN_RSA_KEY_BITS) { | ||
| throw new IllegalArgumentException( | ||
| "RSA key must be at least " + MIN_RSA_KEY_BITS + " bits, was " + bits); | ||
| } | ||
| return JWSAlgorithm.RS256; | ||
| } | ||
| if (privateKey instanceof ECPrivateKey ecKey) { | ||
| Curve curve = Curve.forECParameterSpec(ecKey.getParams()); | ||
| if (!Curve.P_256.equals(curve)) { | ||
| throw new IllegalArgumentException( | ||
| "Unsupported EC curve for ES256 (only P-256 is supported): " + curve); | ||
| } | ||
| return JWSAlgorithm.ES256; | ||
| } | ||
| if (privateKey instanceof EdECPrivateKey edKey) { | ||
| String curveName = edKey.getParams().getName(); | ||
| if (!ED25519.equals(curveName)) { | ||
| throw new IllegalArgumentException( | ||
| "Unsupported EdDSA curve (only Ed25519 is supported): " + curveName); | ||
| } | ||
| return JWSAlgorithm.EdDSA; | ||
| } | ||
| throw new IllegalArgumentException( | ||
| "Unsupported key type for identity proof: " + privateKey.getAlgorithm()); | ||
| } | ||
|
|
||
| /** | ||
| * Computes the JWS signature over the signing input bytes for the resolved algorithm. | ||
| */ | ||
| private Base64URL computeSignature(JWSAlgorithm algorithm, PrivateKey privateKey, | ||
| JWSHeader header, byte[] signingInputBytes) { | ||
| try { | ||
| if (JWSAlgorithm.EdDSA.equals(algorithm)) { | ||
| // Ed25519 JCA signatures are already the raw R||S form JOSE expects, no transcoding needed. | ||
| Signature signature = Signature.getInstance(ED25519); | ||
| signature.initSign(privateKey); | ||
| signature.update(signingInputBytes); | ||
| return Base64URL.encode(signature.sign()); | ||
| } | ||
| JWSSigner signer = JWSAlgorithm.RS256.equals(algorithm) | ||
| ? new RSASSASigner(privateKey) | ||
| : new ECDSASigner(privateKey, Curve.P_256); | ||
| return signer.sign(header, signingInputBytes); | ||
| } catch (GeneralSecurityException | JOSEException e) { | ||
| throw new IllegalStateException( | ||
| "Failed to sign identity proof (alg=" + algorithm + ", kid=" + header.getKeyID() + ")", e); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Builds a public-only JWK for the given public key and resolved algorithm. | ||
| */ | ||
| private JWK toPublicJwk(JWSAlgorithm algorithm, PublicKey publicKey) { | ||
| try { | ||
| if (JWSAlgorithm.RS256.equals(algorithm)) { | ||
| return new RSAKey.Builder((RSAPublicKey) publicKey).build(); | ||
| } | ||
| if (JWSAlgorithm.ES256.equals(algorithm)) { | ||
| return new ECKey.Builder(Curve.P_256, (ECPublicKey) publicKey).build(); | ||
| } | ||
| // EdDSA: the raw 32-byte public key is the tail of the X.509 SubjectPublicKeyInfo encoding. | ||
| if (!(publicKey instanceof EdECPublicKey)) { | ||
| throw new IllegalArgumentException("publicKey does not match the private key algorithm"); | ||
| } | ||
|
Comment on lines
+177
to
+188
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The private key rejects Ed448 at line 141, but toPublicJwk() doesn't apply the equivalent check to the public key. The guard at line 186 is: if (!(publicKey instanceof EdECPublicKey)) { ... }This accepts both Ed25519 and Ed448, since both implement EdECPublicKey. The subsequent extraction: byte[] raw = Arrays.copyOfRange(encoded, encoded.length - ED25519_RAW_KEY_LEN, encoded.length);silently slices the last 32 bytes regardless of the actual key type. For Ed448 (71-byte encoding, 57-byte key), this cuts into the middle of the key material and builds a structurally valid but semantically wrong The fix suggested by Claude is one check mirroring what resolveAlgorithm already does for the private key: // after the instanceof check
EdECPublicKey edPublicKey = (EdECPublicKey) publicKey;
if (!ED25519.equals(edPublicKey.getParams().getName())) {
throw new IllegalArgumentException(
"EdEC public key must use Ed25519 curve, got: " + edPublicKey.getParams().getName());
}
byte[] encoded = edPublicKey.getEncoded();
if (encoded == null) {
throw new IllegalArgumentException("EdEC public key encoding is not available");
}The null check also addresses the |
||
| byte[] encoded = publicKey.getEncoded(); | ||
| byte[] raw = Arrays.copyOfRange(encoded, encoded.length - ED25519_RAW_KEY_LEN, encoded.length); | ||
| return new OctetKeyPair.Builder(Curve.Ed25519, Base64URL.encode(raw)).build(); | ||
| } catch (ClassCastException e) { | ||
| throw new IllegalArgumentException("publicKey does not match the private key algorithm", e); | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Minor: misleading comment at line 158
R||Sis ECDSA notation. Ed25519 produces a 64-byte signature that is not structured asR||Sin the ECDSA sense. The intended meaning is correct (no DER transcoding needed, unlike ECDSA), but the wording will mislead anyone extending this to other EdDSA variants.Should read something like: "Ed25519 JCA output is the raw 64-byte signature per RFC 8037 — no DER transcoding needed unlike ECDSA."