diff --git a/applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java b/applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java index 0d686af..df16eaa 100644 --- a/applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java +++ b/applet/src/main/java/com/dangerousthings/aliro/AliroApplet.java @@ -193,45 +193,10 @@ public class AliroApplet extends Applet { 'V', 'o', 'l', 'a', 't', 'i', 'l', 'e', '*', '*', '*', '*' }; - // secp256r1 / NIST P-256 curve parameters per FIPS 186-4 / SEC2 §2.7.2. - // J3R180 doesn't ship a default P-256 parameter set on its EC keys, so - // calls into genKeyPair / setS / setW / Signature.init throw - // CryptoException.ILLEGAL_VALUE until we seed the curve explicitly. - private static final byte[] SECP256R1_P = { - (byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,0x00,0x00,0x00,0x01, - 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, - 0x00,0x00,0x00,0x00,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF, - (byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF - }; - private static final byte[] SECP256R1_A = { - (byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,0x00,0x00,0x00,0x01, - 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, - 0x00,0x00,0x00,0x00,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF, - (byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFC - }; - private static final byte[] SECP256R1_B = { - 0x5A,(byte)0xC6,0x35,(byte)0xD8,(byte)0xAA,0x3A,(byte)0x93,(byte)0xE7, - (byte)0xB3,(byte)0xEB,(byte)0xBD,0x55,0x76,(byte)0x98,(byte)0x86,(byte)0xBC, - 0x65,0x1D,0x06,(byte)0xB0,(byte)0xCC,0x53,(byte)0xB0,(byte)0xF6, - 0x3B,(byte)0xCE,0x3C,0x3E,0x27,(byte)0xD2,0x60,0x4B - }; - private static final byte[] SECP256R1_G = { - 0x04, - 0x6B,0x17,(byte)0xD1,(byte)0xF2,(byte)0xE1,0x2C,0x42,0x47, - (byte)0xF8,(byte)0xBC,(byte)0xE6,(byte)0xE5,0x63,(byte)0xA4,0x40,(byte)0xF2, - 0x77,0x03,0x7D,(byte)0x81,0x2D,(byte)0xEB,0x33,(byte)0xA0, - (byte)0xF4,(byte)0xA1,0x39,0x45,(byte)0xD8,(byte)0x98,(byte)0xC2,(byte)0x96, - 0x4F,(byte)0xE3,0x42,(byte)0xE2,(byte)0xFE,0x1A,0x7F,(byte)0x9B, - (byte)0x8E,(byte)0xE7,(byte)0xEB,0x4A,0x7C,0x0F,(byte)0x9E,0x16, - 0x2B,(byte)0xCE,0x33,0x57,0x6B,0x31,0x5E,(byte)0xCE, - (byte)0xCB,(byte)0xB6,0x40,0x68,0x37,(byte)0xBF,0x51,(byte)0xF5 - }; - private static final byte[] SECP256R1_R = { - (byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,0x00,0x00,0x00,0x00, - (byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF, - (byte)0xBC,(byte)0xE6,(byte)0xFA,(byte)0xAD,(byte)0xA7,0x17,(byte)0x9E,(byte)0x84, - (byte)0xF3,(byte)0xB9,(byte)0xCA,(byte)0xC2,(byte)0xFC,0x63,0x25,0x51 - }; + // secp256r1 / NIST P-256 curve parameters live in {@link Secp256r1Params} + // — shared with {@link CoseVerifier}. J3R180 doesn't ship a default P-256 + // parameter set on its EC keys, so seedSecp256r1() must run before any + // genKeyPair / setS / setW / Signature.init. /** NFC interface byte, spec §8.3.1.13. */ private static final byte INTERFACE_BYTE_NFC = (byte) 0x5E; /** Table 8-13 usage constant for UD signature (spec §8.3.3.4.3). */ @@ -317,22 +282,12 @@ public class AliroApplet extends Applet { /** Loads the secp256r1 / NIST P-256 curve parameters into both halves of * {@code kp}. Must run before any {@code genKeyPair}, {@code setS}, * {@code setW}, or {@code Signature.init} on cards (like J3R180) that - * don't preset domain parameters on freshly-allocated EC keys. */ + * don't preset domain parameters on freshly-allocated EC keys. + * + *
Thin wrapper over {@link Secp256r1Params#seed(KeyPair)} kept for + * call-site readability; the byte arrays live in the shared class. */ private static void seedSecp256r1(KeyPair kp) { - javacard.security.ECPublicKey pub = (javacard.security.ECPublicKey) kp.getPublic(); - javacard.security.ECPrivateKey priv = (javacard.security.ECPrivateKey) kp.getPrivate(); - pub.setFieldFP(SECP256R1_P, (short) 0, (short) SECP256R1_P.length); - pub.setA(SECP256R1_A, (short) 0, (short) SECP256R1_A.length); - pub.setB(SECP256R1_B, (short) 0, (short) SECP256R1_B.length); - pub.setG(SECP256R1_G, (short) 0, (short) SECP256R1_G.length); - pub.setR(SECP256R1_R, (short) 0, (short) SECP256R1_R.length); - pub.setK((short) 1); - priv.setFieldFP(SECP256R1_P, (short) 0, (short) SECP256R1_P.length); - priv.setA(SECP256R1_A, (short) 0, (short) SECP256R1_A.length); - priv.setB(SECP256R1_B, (short) 0, (short) SECP256R1_B.length); - priv.setG(SECP256R1_G, (short) 0, (short) SECP256R1_G.length); - priv.setR(SECP256R1_R, (short) 0, (short) SECP256R1_R.length); - priv.setK((short) 1); + Secp256r1Params.seed(kp); } // --- Diagnostic test vectors ------------------------------------------- @@ -436,7 +391,7 @@ public class AliroApplet extends Applet { AliroCrypto cryptoEcdh = CryptoSingletons.getAliroCrypto(); for (short i = 0; i < n; i++) { cryptoEcdh.computeEcdhSharedX(priv, - SECP256R1_G, (short) 0, + Secp256r1Params.SECP256R1_G, (short) 0, buf, DIAG_OUT_OFF); } return; diff --git a/applet/src/main/java/com/dangerousthings/aliro/CoseVerifier.java b/applet/src/main/java/com/dangerousthings/aliro/CoseVerifier.java new file mode 100644 index 0000000..2f024c3 --- /dev/null +++ b/applet/src/main/java/com/dangerousthings/aliro/CoseVerifier.java @@ -0,0 +1,306 @@ +package com.dangerousthings.aliro; + +import javacard.framework.JCSystem; +import javacard.framework.Util; +import javacard.security.ECPublicKey; +import javacard.security.KeyBuilder; +import javacard.security.Signature; + +/** + * Verifies an Aliro IssuerAuth COSE_Sign1 (RFC 9052) over ECDSA-P256 + + * SHA-256. Single use case: at personalization we verify the Access + * Document signature ONCE, cache the verdict (M2A.1's + * {@code accessDocumentVerified} flag), and trust thereafter. + * + *
COSE_Sign1 wire format (RFC 9052 §3): + *
+ * [ protected_bstr, unprotected_map, payload_bstr, signature_bstr ] + *+ * Sig_structure that was signed (RFC 9052 §4.4): + *
+ * [ "Signature1", body_protected, external_aad, payload ] + *+ * Signature is raw {@code r || s} (64 bytes for P-256), not DER — + * the JC {@code Signature.ALG_ECDSA_SHA_256} primitive expects DER, so + * we transcode raw → DER before {@link Signature#verify}. + * + *
For now (M2A.2) the CBOR walk uses hardcoded offsets that assume the + * exact wire shape produced by {@code aliro_harness.issuer.cose}: + *
The verifier owns a reusable {@link ECPublicKey} slot, seeded with + * P-256 curve params at construction. Each call calls {@code setW} with + * the caller-supplied uncompressed (0x04 || X || Y) issuer key — no + * per-call allocation. + */ +final class CoseVerifier { + + /** Maximum supported COSE_Sign1 payload length. The Aliro Access + * Document fits comfortably under this; we size the Sig_structure + * working buffer for it. */ + static final short MAX_PAYLOAD = (short) 512; + + /** Maximum supported protected bstr length. Aliro IssuerAuth uses a + * single {alg: ES256} map (3 bytes), but we cap at 32 for slack. */ + static final short MAX_PROTECTED = (short) 32; + + /** Length of a raw P-256 ECDSA signature: r(32) || s(32). */ + private static final short RAW_SIG_LEN = (short) 64; + + /** Length of an uncompressed SEC1 P-256 public key: 0x04 || X(32) || Y(32). */ + private static final short UNCOMP_PUB_LEN = (short) 65; + + /** + * Sig_structure working buffer layout: + *
+ * 0x84 array(4) + * 0x6A tstr(10) — "Signature1" + * 53 69 67 6E 61 "Signature1" (10 bytes) + * 74 75 72 65 31 + * [protected_bstr CBOR header + bytes] + * 0x40 bstr(0) — external_aad (empty) + * [payload_bstr CBOR header + bytes] + *+ * "Signature1" prefix is fixed; cache it. + */ + private static final byte[] SIG_STRUCT_PREFIX = { + (byte) 0x84, // array(4) + (byte) 0x6A, // tstr(10) + (byte) 0x53, (byte) 0x69, (byte) 0x67, (byte) 0x6E, // "Sign" + (byte) 0x61, (byte) 0x74, (byte) 0x75, (byte) 0x72, // "atur" + (byte) 0x65, (byte) 0x31 // "e1" + }; + + private final ECPublicKey issuerPubKey; + private final Signature ecdsaVerifier; + /** Transient scratch for building the Sig_structure and DER signature. + * Sized for MAX_PAYLOAD + MAX_PROTECTED + headers + DER overhead. */ + private final byte[] scratch; + + CoseVerifier() { + issuerPubKey = (ECPublicKey) KeyBuilder.buildKey( + KeyBuilder.TYPE_EC_FP_PUBLIC, + KeyBuilder.LENGTH_EC_FP_256, + false); + Secp256r1Params.seedPublic(issuerPubKey); + ecdsaVerifier = Signature.getInstance(Signature.ALG_ECDSA_SHA_256, false); + // Sig_structure size = prefix(12) + prot_hdr(<=3) + prot_bytes(<=32) + // + ext_aad(1) + pl_hdr(<=3) + pl_bytes(<=512) + // DER sig max = 2 + 2 + 33 + 2 + 33 = 72. + // Round up to keep arithmetic simple. + scratch = JCSystem.makeTransientByteArray( + (short) 768, JCSystem.CLEAR_ON_DESELECT); + } + + /** + * Verifies a COSE_Sign1 IssuerAuth blob against the supplied issuer + * public key. + * + * @param coseSign1 buffer holding the CBOR-encoded COSE_Sign1 + * @param coseOff offset of the outer array tag (0x84) + * @param coseLen total length of the COSE_Sign1 blob + * @param issuerPubUncomp buffer holding the 65-byte uncompressed SEC1 + * pubkey (0x04 || X || Y) + * @param pubOff offset of the 0x04 tag + * @return {@code true} iff the signature verifies; {@code false} on any + * malformed input, mismatched key, or invalid signature. + */ + boolean verifyCoseSign1( + byte[] coseSign1, short coseOff, short coseLen, + byte[] issuerPubUncomp, short pubOff) { + try { + return verifyInternal(coseSign1, coseOff, coseLen, issuerPubUncomp, pubOff); + } catch (Throwable t) { + // Any malformed CBOR, length overrun, crypto exception, etc. + // collapses to "not verified". The personalization caller will + // refuse to install the access document. + return false; + } + } + + private boolean verifyInternal( + byte[] coseSign1, short coseOff, short coseLen, + byte[] issuerPubUncomp, short pubOff) { + short end = (short) (coseOff + coseLen); + + // TODO M2B.3: replace with StructuralCbor.elementSpan — the offset + // arithmetic below assumes the exact wire shape produced by + // aliro_harness.issuer.cose (matches RFC 9052 §3 but is not a + // generic CBOR parser). + short p = coseOff; + + // Outer array(4): only major type 4, count 4, fits in one byte. + if ((short) (p + 1) > end) return false; + if (coseSign1[p] != (byte) 0x84) return false; + p++; + + // protected_bstr: read CBOR bstr header → value off/len. + short protHdrOff = p; + short protLen = readBstrLen(coseSign1, p, end); + if (protLen < 0 || protLen > MAX_PROTECTED) return false; + short protHdrLen = bstrHeaderLen(protLen); + short protValOff = (short) (p + protHdrLen); + if ((short) (protValOff + protLen) > end) return false; + p = (short) (protValOff + protLen); + + // unprotected_map: skip — its contents (kid etc.) don't enter + // Sig_structure. Walk the map(1) and its single key/value pair. + // CBOR major type 5 (map). We only need to skip; assume map(1) + // shape per IssuerAuth profile (0xA1). + if ((short) (p + 1) > end) return false; + if ((coseSign1[p] & (byte) 0xE0) != (byte) 0xA0) return false; + short mapCount = (short) (coseSign1[p] & 0x1F); + if (mapCount > 23) return false; // we don't handle long-form map headers + p++; + for (short i = 0; i < mapCount; i++) { + // key: small int (kid header field 4 → 1 byte 0x04). We only + // support single-byte keys. + if ((short) (p + 1) > end) return false; + p++; + // value: bstr (kid) — read header + skip body. + short kLen = readBstrLen(coseSign1, p, end); + if (kLen < 0) return false; + short kHdr = bstrHeaderLen(kLen); + p = (short) (p + kHdr + kLen); + if (p > end) return false; + } + + // payload_bstr. + short payloadHdrOff = p; + short payloadLen = readBstrLen(coseSign1, p, end); + if (payloadLen < 0 || payloadLen > MAX_PAYLOAD) return false; + short payloadHdrLen = bstrHeaderLen(payloadLen); + short payloadValOff = (short) (p + payloadHdrLen); + if ((short) (payloadValOff + payloadLen) > end) return false; + p = (short) (payloadValOff + payloadLen); + + // signature_bstr: must be raw 64-byte P-256 ECDSA (r||s). + short sigLen = readBstrLen(coseSign1, p, end); + if (sigLen != RAW_SIG_LEN) return false; + short sigHdrLen = bstrHeaderLen(sigLen); + short sigValOff = (short) (p + sigHdrLen); + if ((short) (sigValOff + sigLen) > end) return false; + + // Build Sig_structure into scratch. + short s = (short) 0; + Util.arrayCopyNonAtomic(SIG_STRUCT_PREFIX, (short) 0, + scratch, s, (short) SIG_STRUCT_PREFIX.length); + s += (short) SIG_STRUCT_PREFIX.length; + // protected_bstr (re-emit header so it matches input verbatim). + s = writeBstr(coseSign1, protValOff, protLen, scratch, s); + // external_aad = empty bstr (h''): single byte 0x40. + scratch[s++] = (byte) 0x40; + // payload bstr. + s = writeBstr(coseSign1, payloadValOff, payloadLen, scratch, s); + + // Place the DER-encoded signature after the Sig_structure. + short derLen = rawSigToDer(coseSign1, sigValOff, scratch, s); + short derOff = s; + + // Load issuer pubkey. + issuerPubKey.setW(issuerPubUncomp, pubOff, UNCOMP_PUB_LEN); + ecdsaVerifier.init(issuerPubKey, Signature.MODE_VERIFY); + return ecdsaVerifier.verify( + scratch, (short) 0, s, + scratch, derOff, derLen); + } + + /** + * Reads a CBOR byte-string length field at {@code off}. Supports the + * inline (len ≤ 23, major-type byte 0x40+len), 1-byte-extended + * (0x58 + uint8), and 2-byte-extended (0x59 + uint16) encodings. + * + * @return the bstr value length, or -1 if not a bstr or overflow. + */ + private static short readBstrLen(byte[] buf, short off, short end) { + if ((short) (off + 1) > end) return -1; + byte b0 = buf[off]; + if ((b0 & (byte) 0xE0) != (byte) 0x40) return -1; // not bstr major type + short shortLen = (short) (b0 & 0x1F); + if (shortLen <= 23) return shortLen; + if (shortLen == 24) { + if ((short) (off + 2) > end) return -1; + return (short) (buf[(short) (off + 1)] & 0xFF); + } + if (shortLen == 25) { + if ((short) (off + 3) > end) return -1; + short hi = (short) ((buf[(short) (off + 1)] & 0xFF) << 8); + short lo = (short) (buf[(short) (off + 2)] & 0xFF); + return (short) (hi | lo); + } + return -1; + } + + /** Total header byte count for a bstr of {@code len} bytes (matches + * what {@link #readBstrLen} parses). */ + private static short bstrHeaderLen(short len) { + if (len <= 23) return 1; + if (len <= 0xFF) return 2; + return 3; + } + + /** Copies {@code len} bytes from {@code src[srcOff..]} into {@code dst} + * prefixed by a freshly-emitted CBOR bstr header. Returns the post-write + * offset into {@code dst}. */ + private static short writeBstr(byte[] src, short srcOff, short len, + byte[] dst, short dstOff) { + if (len <= 23) { + dst[dstOff++] = (byte) (0x40 | len); + } else if (len <= 0xFF) { + dst[dstOff++] = (byte) 0x58; + dst[dstOff++] = (byte) len; + } else { + dst[dstOff++] = (byte) 0x59; + dst[dstOff++] = (byte) ((len >> 8) & 0xFF); + dst[dstOff++] = (byte) (len & 0xFF); + } + Util.arrayCopyNonAtomic(src, srcOff, dst, dstOff, len); + return (short) (dstOff + len); + } + + /** + * Converts a 64-byte raw ECDSA (r||s) signature into ASN.1 DER: + * {@code SEQUENCE { INTEGER r, INTEGER s }}. Both r and s are written + * without leading-zero stripping, and a 0x00 is prepended if the high + * bit of the first byte is set (to keep the INTEGER positive). + * + *
Duplicates {@code AliroApplet.rawSigToDer} verbatim — both + * callers are private and Java Card 1.7 has no facility for a shared + * package-level helper without a separate utility class. M2B.3 may + * consolidate. + */ + private static short rawSigToDer(byte[] raw, short rawOff, + byte[] out, short outOff) { + boolean rPad = (raw[rawOff] & 0x80) != 0; + boolean sPad = (raw[(short) (rawOff + 32)] & 0x80) != 0; + short rLen = rPad ? (short) 33 : (short) 32; + short sLen = sPad ? (short) 33 : (short) 32; + short contentLen = (short) (2 + rLen + 2 + sLen); + + short p = outOff; + out[p++] = (byte) 0x30; + out[p++] = (byte) contentLen; + + out[p++] = (byte) 0x02; + out[p++] = (byte) rLen; + if (rPad) out[p++] = (byte) 0x00; + Util.arrayCopyNonAtomic(raw, rawOff, out, p, (short) 32); + p += 32; + + out[p++] = (byte) 0x02; + out[p++] = (byte) sLen; + if (sPad) out[p++] = (byte) 0x00; + Util.arrayCopyNonAtomic(raw, (short) (rawOff + 32), out, p, (short) 32); + p += 32; + + return (short) (p - outOff); + } +} diff --git a/applet/src/main/java/com/dangerousthings/aliro/Secp256r1Params.java b/applet/src/main/java/com/dangerousthings/aliro/Secp256r1Params.java new file mode 100644 index 0000000..28abca0 --- /dev/null +++ b/applet/src/main/java/com/dangerousthings/aliro/Secp256r1Params.java @@ -0,0 +1,84 @@ +package com.dangerousthings.aliro; + +/** + * secp256r1 / NIST P-256 curve parameters per FIPS 186-4 / SEC2 §2.7.2. + * + *
Shared across {@link AliroApplet} and {@link CoseVerifier} (and any + * future Aliro applet that needs to seed an EC key). J3R180 ships ECC keys + * with no default domain parameters; without explicitly seeding the curve, + * calls into {@code genKeyPair} / {@code setS} / {@code setW} / + * {@code Signature.init} throw {@code CryptoException.ILLEGAL_VALUE}. + * + *
Package-private — only intra-package callers should depend on these. + */ +final class Secp256r1Params { + + private Secp256r1Params() { } + + static final byte[] SECP256R1_P = { + (byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,0x00,0x00,0x00,0x01, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF, + (byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF + }; + static final byte[] SECP256R1_A = { + (byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,0x00,0x00,0x00,0x01, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF, + (byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFC + }; + static final byte[] SECP256R1_B = { + 0x5A,(byte)0xC6,0x35,(byte)0xD8,(byte)0xAA,0x3A,(byte)0x93,(byte)0xE7, + (byte)0xB3,(byte)0xEB,(byte)0xBD,0x55,0x76,(byte)0x98,(byte)0x86,(byte)0xBC, + 0x65,0x1D,0x06,(byte)0xB0,(byte)0xCC,0x53,(byte)0xB0,(byte)0xF6, + 0x3B,(byte)0xCE,0x3C,0x3E,0x27,(byte)0xD2,0x60,0x4B + }; + static final byte[] SECP256R1_G = { + 0x04, + 0x6B,0x17,(byte)0xD1,(byte)0xF2,(byte)0xE1,0x2C,0x42,0x47, + (byte)0xF8,(byte)0xBC,(byte)0xE6,(byte)0xE5,0x63,(byte)0xA4,0x40,(byte)0xF2, + 0x77,0x03,0x7D,(byte)0x81,0x2D,(byte)0xEB,0x33,(byte)0xA0, + (byte)0xF4,(byte)0xA1,0x39,0x45,(byte)0xD8,(byte)0x98,(byte)0xC2,(byte)0x96, + 0x4F,(byte)0xE3,0x42,(byte)0xE2,(byte)0xFE,0x1A,0x7F,(byte)0x9B, + (byte)0x8E,(byte)0xE7,(byte)0xEB,0x4A,0x7C,0x0F,(byte)0x9E,0x16, + 0x2B,(byte)0xCE,0x33,0x57,0x6B,0x31,0x5E,(byte)0xCE, + (byte)0xCB,(byte)0xB6,0x40,0x68,0x37,(byte)0xBF,0x51,(byte)0xF5 + }; + static final byte[] SECP256R1_R = { + (byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,0x00,0x00,0x00,0x00, + (byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF,(byte)0xFF, + (byte)0xBC,(byte)0xE6,(byte)0xFA,(byte)0xAD,(byte)0xA7,0x17,(byte)0x9E,(byte)0x84, + (byte)0xF3,(byte)0xB9,(byte)0xCA,(byte)0xC2,(byte)0xFC,0x63,0x25,0x51 + }; + + /** + * Loads the secp256r1 / NIST P-256 curve parameters into both halves of + * {@code kp}. Must run before any {@code genKeyPair}, {@code setS}, + * {@code setW}, or {@code Signature.init} on cards (like J3R180) that + * don't preset domain parameters on freshly-allocated EC keys. + */ + static void seed(javacard.security.KeyPair kp) { + javacard.security.ECPublicKey pub = (javacard.security.ECPublicKey) kp.getPublic(); + javacard.security.ECPrivateKey priv = (javacard.security.ECPrivateKey) kp.getPrivate(); + seedPublic(pub); + priv.setFieldFP(SECP256R1_P, (short) 0, (short) SECP256R1_P.length); + priv.setA(SECP256R1_A, (short) 0, (short) SECP256R1_A.length); + priv.setB(SECP256R1_B, (short) 0, (short) SECP256R1_B.length); + priv.setG(SECP256R1_G, (short) 0, (short) SECP256R1_G.length); + priv.setR(SECP256R1_R, (short) 0, (short) SECP256R1_R.length); + priv.setK((short) 1); + } + + /** + * Seeds curve params on a standalone public key (for verify-only flows + * like {@link CoseVerifier} that never need a matching private key). + */ + static void seedPublic(javacard.security.ECPublicKey pub) { + pub.setFieldFP(SECP256R1_P, (short) 0, (short) SECP256R1_P.length); + pub.setA(SECP256R1_A, (short) 0, (short) SECP256R1_A.length); + pub.setB(SECP256R1_B, (short) 0, (short) SECP256R1_B.length); + pub.setG(SECP256R1_G, (short) 0, (short) SECP256R1_G.length); + pub.setR(SECP256R1_R, (short) 0, (short) SECP256R1_R.length); + pub.setK((short) 1); + } +} diff --git a/applet/src/test/java/com/dangerousthings/aliro/CoseVerifierTest.java b/applet/src/test/java/com/dangerousthings/aliro/CoseVerifierTest.java new file mode 100644 index 0000000..892ed36 --- /dev/null +++ b/applet/src/test/java/com/dangerousthings/aliro/CoseVerifierTest.java @@ -0,0 +1,86 @@ +package com.dangerousthings.aliro; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests for {@link CoseVerifier} — RFC 9052 COSE_Sign1 verification over + * ECDSA P-256 + SHA-256, used to authenticate the Aliro Access Document + * IssuerAuth signature. + * + *
Test vector generated once by harness/src/aliro_harness/issuer/cose.py + * using a deterministic private key seed ("M2A.2-cose-verifier-test-key!!!_" + * reduced mod n) and the literal payload "M2A.2 known-good payload". + * Self-verified via verify_cose_sign1 before being copied here. + * + *
ECDSA k is randomised inside the python {@code cryptography} library; + * regenerating the vector would produce a different signature. The hex + * constants below are the canonical M2A.2 known-answer pair. + */ +class CoseVerifierTest { + + /** secp256r1 public key, uncompressed SEC1 (0x04 || X(32) || Y(32)). */ + private static final byte[] ISSUER_PUB_UNCOMP = hex( + "04d28f7bbdb8bc7c4d1bbcb7b76e6bdffaa4994f978f316df8d5341bf164e36b82" + + "76f90f72b5680bfd21901de452ea133f35ef44fd34e5b14ddb9a7e14ca700a0a"); + + /** + * COSE_Sign1 wire bytes (CBOR-encoded 4-element array) for the test + * vector above. Layout: + *
+ * [ 0] 0x84 outer array(4)
+ * [ 1] 0x43 protected bstr header (len 3)
+ * [ 2..4] a1 01 26 {1: -7} → ES256 alg id
+ * [ 5] 0xa1 unprotected map(1)
+ * [ 6] 0x04 key 4 (kid)
+ * [ 7] 0x48 bstr(8)
+ * [ 8..15] kid 01 02 03 04 05 06 07 08
+ * [ 16] 0x58 payload bstr 1-byte len
+ * [ 17] 0x18 len = 24
+ * [ 18..41] "M2A.2 known-good payload" (24 bytes)
+ * [ 42] 0x58 sig bstr 1-byte len
+ * [ 43] 0x40 len = 64
+ * [ 44..107] raw r||s ECDSA-P256 signature (64 bytes)
+ *
+ */
+ private static final byte[] COSE_SIGN1_GOOD = hex(
+ "8443a10126a1044801020304050607085818"
+ + "4d32412e32206b6e6f776e2d676f6f64207061796c6f6164"
+ + "5840"
+ + "02e1c7996e16b3c47c020d7894f38f2c3850abc10a1a53dd56051c80412ba203"
+ + "447598813bab380e0d286dc55985c24d123a7bc98f1f31419679d4f2dde0e916");
+
+ @Test
+ void verifyCoseSign1_validSignature_returnsTrue() {
+ CoseVerifier verifier = new CoseVerifier();
+ boolean ok = verifier.verifyCoseSign1(
+ COSE_SIGN1_GOOD, (short) 0, (short) COSE_SIGN1_GOOD.length,
+ ISSUER_PUB_UNCOMP, (short) 0);
+ assertTrue(ok, "known-good COSE_Sign1 must verify against issuer pubkey");
+ }
+
+ @Test
+ void verifyCoseSign1_tamperedPayload_returnsFalse() {
+ // Flip one bit in the payload region (offset 25 = mid-payload).
+ byte[] tampered = new byte[COSE_SIGN1_GOOD.length];
+ System.arraycopy(COSE_SIGN1_GOOD, 0, tampered, 0, COSE_SIGN1_GOOD.length);
+ tampered[25] ^= (byte) 0x01;
+
+ CoseVerifier verifier = new CoseVerifier();
+ boolean ok = verifier.verifyCoseSign1(
+ tampered, (short) 0, (short) tampered.length,
+ ISSUER_PUB_UNCOMP, (short) 0);
+ assertFalse(ok, "tampered payload must NOT verify");
+ }
+
+ private static byte[] hex(String s) {
+ s = s.replaceAll("\\s+", "");
+ byte[] out = new byte[s.length() / 2];
+ for (int i = 0; i < out.length; i++) {
+ out[i] = (byte) Integer.parseInt(s.substring(i * 2, i * 2 + 2), 16);
+ }
+ return out;
+ }
+}