From 40a079a539b85c91da4bbad62cf28f7caa5cc9c8 Mon Sep 17 00:00:00 2001 From: michael Date: Tue, 26 May 2026 10:50:47 -0700 Subject: [PATCH] feat: define CredentialStore serialization contract Adds in-tree SerializationSink/Source interfaces matching the shape of the AMD-H Element API, plus writeTo/readFrom on CredentialStore and a round-trip test. The AMD-H adapter (added in a later epic) just bridges Element -> these interfaces, so the field layout is testable today without depending on a JCOP 4.5 image. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../aliro/CredentialStore.java | 86 +++++++++++ .../aliro/SerializationSink.java | 23 +++ .../aliro/SerializationSource.java | 13 ++ .../CredentialStoreSerializationTest.java | 144 ++++++++++++++++++ 4 files changed, 266 insertions(+) create mode 100644 applet/src/main/java/com/dangerousthings/aliro/SerializationSink.java create mode 100644 applet/src/main/java/com/dangerousthings/aliro/SerializationSource.java create mode 100644 applet/src/test/java/com/dangerousthings/aliro/CredentialStoreSerializationTest.java diff --git a/applet/src/main/java/com/dangerousthings/aliro/CredentialStore.java b/applet/src/main/java/com/dangerousthings/aliro/CredentialStore.java index f8c5ca6..99945a9 100644 --- a/applet/src/main/java/com/dangerousthings/aliro/CredentialStore.java +++ b/applet/src/main/java/com/dangerousthings/aliro/CredentialStore.java @@ -33,6 +33,11 @@ final class CredentialStore { * accommodates a typical Aliro Access Document with room to spare. */ static final short ACCESS_DOC_MAX_LEN = 1024; + /** Stable field order for AMD-H Element serialization. Append-only — + * never reorder or remove without bumping the package version and + * writing an explicit migration step in the new ELF's onRestore. */ + static final byte FIELD_VERSION = 1; + /** Publish-point read by AliroApplet/StepUpApplet via {@link #get()}. * PersonalizationApplet owns the actual instance; this is just an alias * so other applets in the same package can find it without SIO. The @@ -194,6 +199,87 @@ final class CredentialStore { return len; } + /** + * Writes the full credential store state to the given sink in + * {@link #FIELD_VERSION} layout. The field order is locked — see the + * FIELD_VERSION javadoc. + */ + void writeTo(SerializationSink sink) { + sink.write(FIELD_VERSION); + sink.write(committed); + sink.write(credentialPrivKeySet); + sink.write(credentialPubKeySet); + sink.write(readerPubKeySet); + sink.write(accessDocumentFinalized); + sink.write(accessDocumentLen); + sink.write(credentialPrivKey); + sink.write(credentialPubKey); + sink.write(readerPubKey); + sink.write(accessDocument); + } + + /** + * Reads a full credential store snapshot from the given source written + * by {@link #writeTo(SerializationSink)}. + */ + static CredentialStore readFrom(SerializationSource src) { + byte v = src.readByte(); + if (v != FIELD_VERSION) { + javacard.framework.ISOException.throwIt( + javacard.framework.ISO7816.SW_DATA_INVALID); + } + CredentialStore s = new CredentialStore(); + s.committed = src.readBoolean(); + s.credentialPrivKeySet = src.readBoolean(); + s.credentialPubKeySet = src.readBoolean(); + s.readerPubKeySet = src.readBoolean(); + s.accessDocumentFinalized = src.readBoolean(); + s.accessDocumentLen = src.readShort(); + byte[] a; + a = src.readByteArray(); + javacard.framework.Util.arrayCopy(a, (short) 0, s.credentialPrivKey, (short) 0, CRED_PRIV_KEY_LEN); + a = src.readByteArray(); + javacard.framework.Util.arrayCopy(a, (short) 0, s.credentialPubKey, (short) 0, CRED_PUBK_LEN); + a = src.readByteArray(); + javacard.framework.Util.arrayCopy(a, (short) 0, s.readerPubKey, (short) 0, READER_PUBK_LEN); + a = src.readByteArray(); + javacard.framework.Util.arrayCopy(a, (short) 0, s.accessDocument, (short) 0, ACCESS_DOC_MAX_LEN); + return s; + } + + /** Test-only: returns a fresh byte[] copy of the credential private key. */ + byte[] copyCredentialPrivKey() { + byte[] out = new byte[CRED_PRIV_KEY_LEN]; + javacard.framework.Util.arrayCopyNonAtomic( + credentialPrivKey, (short) 0, out, (short) 0, CRED_PRIV_KEY_LEN); + return out; + } + + /** Test-only: returns a fresh byte[] copy of the credential public key. */ + byte[] copyCredentialPubKey() { + byte[] out = new byte[CRED_PUBK_LEN]; + javacard.framework.Util.arrayCopyNonAtomic( + credentialPubKey, (short) 0, out, (short) 0, CRED_PUBK_LEN); + return out; + } + + /** Test-only: returns a fresh byte[] copy of the reader public key. */ + byte[] copyReaderPubKey() { + byte[] out = new byte[READER_PUBK_LEN]; + javacard.framework.Util.arrayCopyNonAtomic( + readerPubKey, (short) 0, out, (short) 0, READER_PUBK_LEN); + return out; + } + + /** Test-only: returns a fresh byte[] copy of the active Access Document + * bytes (length = {@link #accessDocumentLen}, not the full buffer). */ + byte[] copyAccessDocument() { + byte[] out = new byte[accessDocumentLen]; + javacard.framework.Util.arrayCopyNonAtomic( + accessDocument, (short) 0, out, (short) 0, accessDocumentLen); + return out; + } + /** * Test-only hook. The store is a static singleton, which makes unit * tests interfere with each other unless each test starts from a diff --git a/applet/src/main/java/com/dangerousthings/aliro/SerializationSink.java b/applet/src/main/java/com/dangerousthings/aliro/SerializationSink.java new file mode 100644 index 0000000..c581d89 --- /dev/null +++ b/applet/src/main/java/com/dangerousthings/aliro/SerializationSink.java @@ -0,0 +1,23 @@ +package com.dangerousthings.aliro; + +/** + * Minimal write-side abstraction matching the shape of the GlobalPlatform + * Amendment H {@code org.globalplatform.upgrade.Element} write API. The + * AMD-H adapter (added in a later epic, J3R452-only) is a one-class bridge + * that implements this interface against the real {@code Element}. + * + *

Keeping the interface in the main source tree means + * {@link CredentialStore#writeTo(SerializationSink)} can be authored, + * tested, and locked down today without pulling in the AMD-H package, which + * only exists on JCOP 4.5 / J3R452-class cards. + * + *

Element semantics: once a byte[] reference is written, the storage + * provider treats it as immutable from the caller's perspective. Test + * fakes that buffer writes should make defensive copies to mirror this. + */ +interface SerializationSink { + SerializationSink write(byte b); + SerializationSink write(short s); + SerializationSink write(boolean z); + SerializationSink write(byte[] arr); +} diff --git a/applet/src/main/java/com/dangerousthings/aliro/SerializationSource.java b/applet/src/main/java/com/dangerousthings/aliro/SerializationSource.java new file mode 100644 index 0000000..36d0e51 --- /dev/null +++ b/applet/src/main/java/com/dangerousthings/aliro/SerializationSource.java @@ -0,0 +1,13 @@ +package com.dangerousthings.aliro; + +/** + * Minimal read-side abstraction matching the shape of the GlobalPlatform + * Amendment H {@code org.globalplatform.upgrade.Element} read API. See + * {@link SerializationSink} for the rationale. + */ +interface SerializationSource { + byte readByte(); + short readShort(); + boolean readBoolean(); + byte[] readByteArray(); +} diff --git a/applet/src/test/java/com/dangerousthings/aliro/CredentialStoreSerializationTest.java b/applet/src/test/java/com/dangerousthings/aliro/CredentialStoreSerializationTest.java new file mode 100644 index 0000000..b3b505d --- /dev/null +++ b/applet/src/test/java/com/dangerousthings/aliro/CredentialStoreSerializationTest.java @@ -0,0 +1,144 @@ +package com.dangerousthings.aliro; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Locks down the byte-for-byte field order written by + * {@link CredentialStore#writeTo(SerializationSink)} / read back by + * {@link CredentialStore#readFrom(SerializationSource)}. + * + *

This test exists today against an in-tree + * {@link SerializationSink}/{@link SerializationSource} pair (see + * {@code RecordingSink} below) so the field shape is testable before the + * GP Amendment H {@code org.globalplatform.upgrade.Element} adapter is + * wired in. When the AMD-H adapter lands, it just bridges {@code Element} + * to those two interfaces and this test stays the regression spec. + */ +class CredentialStoreSerializationTest { + + private static final byte[] KNOWN_32_BYTES = makeRange(32, 0xA0); + private static final byte[] KNOWN_64_BYTES = makeRange(64, 0x10); + private static final byte[] KNOWN_64_BYTES_B = makeRange(64, 0x80); + private static final byte[] KNOWN_DOC_BYTES = makeDoc(128); + + private static byte[] makeRange(int len, int start) { + byte[] out = new byte[len]; + for (int i = 0; i < len; i++) { + out[i] = (byte) (start + i); + } + return out; + } + + private static byte[] makeDoc(int len) { + byte[] out = new byte[len]; + for (int i = 0; i < len; i++) { + // A distinguishable, non-trivial pattern that isn't a simple + // run — flushes out off-by-one read/write bugs. + out[i] = (byte) ((i * 31 + 7) & 0xFF); + } + return out; + } + + @Test + void credentialStoreRoundTripsViaSerializationContract() { + CredentialStore src = CredentialStore.bootstrap(); + src.resetForTesting(); + src.setCredentialPrivKey(KNOWN_32_BYTES, (short) 0); + src.setCredentialPubKey(KNOWN_64_BYTES, (short) 0); + src.setReaderPubKey(KNOWN_64_BYTES_B, (short) 0); + src.writeAccessDocumentChunk(KNOWN_DOC_BYTES, (short) 0, (short) 0, + (short) KNOWN_DOC_BYTES.length); + src.finalizeAccessDocument((short) KNOWN_DOC_BYTES.length); + src.commit(); + + RecordingSink buf = new RecordingSink(); + src.writeTo(buf); + + CredentialStore restored = CredentialStore.readFrom(buf.toSource()); + + assertArrayEquals(KNOWN_32_BYTES, restored.copyCredentialPrivKey()); + assertArrayEquals(KNOWN_64_BYTES, restored.copyCredentialPubKey()); + assertArrayEquals(KNOWN_64_BYTES_B, restored.copyReaderPubKey()); + assertArrayEquals(KNOWN_DOC_BYTES, restored.copyAccessDocument()); + assertTrue(restored.isCommitted()); + } + + /** + * Test-only buffer that captures sink writes as a typed event log and + * replays them as a {@link SerializationSource}. Mirrors AMD-H + * {@code Element} semantics by making a defensive copy of every + * {@code byte[]} handed to {@link #write(byte[])} — once written, the + * stored data must not be affected by subsequent caller mutations. + */ + private static final class RecordingSink implements SerializationSink { + private final List events = new ArrayList(); + + @Override + public SerializationSink write(byte b) { + events.add(Byte.valueOf(b)); + return this; + } + + @Override + public SerializationSink write(short s) { + events.add(Short.valueOf(s)); + return this; + } + + @Override + public SerializationSink write(boolean z) { + events.add(Boolean.valueOf(z)); + return this; + } + + @Override + public SerializationSink write(byte[] arr) { + // Defensive copy: Element treats stored references as + // immutable from the caller's perspective. + byte[] copy = new byte[arr.length]; + System.arraycopy(arr, 0, copy, 0, arr.length); + events.add(copy); + return this; + } + + SerializationSource toSource() { + return new ReplaySource(events); + } + } + + private static final class ReplaySource implements SerializationSource { + private final List events; + private int idx; + + ReplaySource(List events) { + this.events = events; + this.idx = 0; + } + + @Override + public byte readByte() { + return ((Byte) events.get(idx++)).byteValue(); + } + + @Override + public short readShort() { + return ((Short) events.get(idx++)).shortValue(); + } + + @Override + public boolean readBoolean() { + return ((Boolean) events.get(idx++)).booleanValue(); + } + + @Override + public byte[] readByteArray() { + return (byte[]) events.get(idx++); + } + } +}