feat(applet): CoseVerifier RFC 9052 verify (M2A.2)

Adds CoseVerifier.verifyCoseSign1 — single-purpose RFC 9052 COSE_Sign1
verifier for the Aliro IssuerAuth signature, used once at personalization
(M2A.3) to set CredentialStore.accessDocumentVerified.

Wire walk uses hardcoded offsets that assume the
aliro_harness.issuer.cose shape (matches RFC 9052 §3 but is not a generic
CBOR parser). TODO marker for M2B.3 to swap in StructuralCbor.elementSpan.

Extracts SECP256R1_{P,A,B,G,R} into Secp256r1Params so AliroApplet and
CoseVerifier share one copy. seed(KeyPair) handles full keypair seeding;
seedPublic(ECPublicKey) covers the verify-only case for the new verifier.

Test vector: deterministic-seeded P-256 key + literal payload, signed
once via the harness cose helpers and self-verified, hex-pasted into
CoseVerifierTest. Two cases (good signature; flipped payload byte).

Regression: 80 tests green; the 3 pre-existing GCM errors from the
jcardsim m2-volume swap (AliroCryptoTest#jcardsimSupportsAesGcm,
AliroGcmTest#{partialFinalBlock,roundTripAgainstJcardsimAEADCipher})
still error, unrelated to this change.
This commit is contained in:
michael
2026-06-17 14:07:47 -07:00
parent 531b1d3186
commit 3868678bf9
4 changed files with 486 additions and 55 deletions

View File

@@ -193,45 +193,10 @@ public class AliroApplet extends Applet {
'V', 'o', 'l', 'a', 't', 'i', 'l', 'e', '*', '*', '*', '*' 'V', 'o', 'l', 'a', 't', 'i', 'l', 'e', '*', '*', '*', '*'
}; };
// secp256r1 / NIST P-256 curve parameters per FIPS 186-4 / SEC2 §2.7.2. // secp256r1 / NIST P-256 curve parameters live in {@link Secp256r1Params}
// J3R180 doesn't ship a default P-256 parameter set on its EC keys, so // — shared with {@link CoseVerifier}. J3R180 doesn't ship a default P-256
// calls into genKeyPair / setS / setW / Signature.init throw // parameter set on its EC keys, so seedSecp256r1() must run before any
// CryptoException.ILLEGAL_VALUE until we seed the curve explicitly. // genKeyPair / setS / setW / Signature.init.
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
};
/** NFC interface byte, spec §8.3.1.13. */ /** NFC interface byte, spec §8.3.1.13. */
private static final byte INTERFACE_BYTE_NFC = (byte) 0x5E; private static final byte INTERFACE_BYTE_NFC = (byte) 0x5E;
/** Table 8-13 usage constant for UD signature (spec §8.3.3.4.3). */ /** 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 /** Loads the secp256r1 / NIST P-256 curve parameters into both halves of
* {@code kp}. Must run before any {@code genKeyPair}, {@code setS}, * {@code kp}. Must run before any {@code genKeyPair}, {@code setS},
* {@code setW}, or {@code Signature.init} on cards (like J3R180) that * {@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.
*
* <p>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) { private static void seedSecp256r1(KeyPair kp) {
javacard.security.ECPublicKey pub = (javacard.security.ECPublicKey) kp.getPublic(); Secp256r1Params.seed(kp);
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);
} }
// --- Diagnostic test vectors ------------------------------------------- // --- Diagnostic test vectors -------------------------------------------
@@ -436,7 +391,7 @@ public class AliroApplet extends Applet {
AliroCrypto cryptoEcdh = CryptoSingletons.getAliroCrypto(); AliroCrypto cryptoEcdh = CryptoSingletons.getAliroCrypto();
for (short i = 0; i < n; i++) { for (short i = 0; i < n; i++) {
cryptoEcdh.computeEcdhSharedX(priv, cryptoEcdh.computeEcdhSharedX(priv,
SECP256R1_G, (short) 0, Secp256r1Params.SECP256R1_G, (short) 0,
buf, DIAG_OUT_OFF); buf, DIAG_OUT_OFF);
} }
return; return;

View File

@@ -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.
*
* <p>COSE_Sign1 wire format (RFC 9052 §3):
* <pre>
* [ protected_bstr, unprotected_map, payload_bstr, signature_bstr ]
* </pre>
* Sig_structure that was signed (RFC 9052 §4.4):
* <pre>
* [ "Signature1", body_protected, external_aad, payload ]
* </pre>
* 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}.
*
* <p>For now (M2A.2) the CBOR walk uses hardcoded offsets that assume the
* exact wire shape produced by {@code aliro_harness.issuer.cose}:
* <ul>
* <li>outer array(4) tag = 0x84 at byte 0
* <li>protected_bstr is a single-byte-length bstr at byte 1
* <li>unprotected map is map(1) with kid bstr value
* <li>payload_bstr is either tiny (header 0x40+len) or 1-byte len (0x58 + len)
* <li>signature_bstr is 0x58 0x40 + 64 bytes
* </ul>
* M2B.3 will replace these hardcoded offsets with
* {@code StructuralCbor.elementSpan} for true structural walking.
*
* <p>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:
* <pre>
* 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]
* </pre>
* "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).
*
* <p>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);
}
}

View File

@@ -0,0 +1,84 @@
package com.dangerousthings.aliro;
/**
* secp256r1 / NIST P-256 curve parameters per FIPS 186-4 / SEC2 §2.7.2.
*
* <p>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}.
*
* <p>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);
}
}

View File

@@ -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.
*
* <p>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.
*
* <p>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:
* <pre>
* [ 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)
* </pre>
*/
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;
}
}