[java-idp-plugin-vci] branch dev/STATUS_LIST updated: Status List implementation, allocating, storing, publishing and revoking

Codeberg noreply at shibboleth.net
Fri Aug 21 09:41:17 UTC 2026


This is an automated email from the git hooks/post-receive script.

codeberg pushed a commit to branch dev/STATUS_LIST
in repository java-idp-plugin-vci.

View the commit online:
https://codeberg.org/Shibboleth/java-idp-plugin-vci/commit/95d8fa34ce137152c38ae8d6405db8c13aa283e9

The following commit(s) were added to refs/heads/dev/STATUS_LIST by this push:
     new 95d8fa3  Status List implementation, allocating, storing, publishing and revoking
95d8fa3 is described below

commit 95d8fa34ce137152c38ae8d6405db8c13aa283e9
Author: Janne Lauros <janne.lauros at csc.fi>
AuthorDate: Fri Aug 21 12:41:00 2026 +0300

    Status List implementation, allocating, storing, publishing and revoking
---
 .../oauth/statuslist/MonthlyBucketStrategy.java    |  83 +++++++
 ...{StatusListIndex.java => StatusListBucket.java} |  19 +-
 .../plugin/oauth/statuslist/StatusListCodec.java   | 159 +++++++++++++
 .../oauth/statuslist/StatusListParameters.java     | 102 ++++++++
 .../statuslist/context/StatusListContext.java      |  77 +++---
 .../statuslist/context/StatusListTokenContext.java |  79 +++++++
 .../messaging/error/StatusListError.java           |  47 ++++
 .../statuslist/profile/StatusListEventIds.java     |  42 ++++
 .../openidvci/credential/CredentialDefinition.java |  27 +--
 .../credential/CredentialConfigurationTest.java    |   1 -
 .../credential/CredentialDefinitionTest.java       |   3 +-
 .../credentials/CredentialDefinition.json          |   3 +-
 .../logic/CredentialIssuerLookupFunction.java      | 156 ++++++++++++
 .../DefaultStatusListAssignmentsConfiguration.java |  41 ++++
 .../config/DefaultStatusListConfiguration.java     |  46 ++++
 .../DefaultStatusListRevokeConfiguration.java      |  41 ++++
 .../impl/StatusListAssignmentsRequestDecoder.java  |  58 +++++
 .../impl/StatusListRevokeRequestDecoder.java       |  59 +++++
 .../impl/StatusListAssignmentsRequest.java         | 196 ++++++++++++++++
 .../impl/StatusListAssignmentsSuccessResponse.java |  83 +++++++
 .../messaging/impl/StatusListRevokeRequest.java    | 180 ++++++++++++++
 .../impl/StatusListRevokeSuccessResponse.java      |  44 ++++
 .../messaging/impl/StatusListSuccessResponse.java  |  94 ++++++++
 .../statuslist/profile/impl/AllocateIndex.java     | 111 +++++++--
 .../oauth/statuslist/profile/impl/AssignIndex.java | 167 +++++++++++++
 ...tboundStatusListAssignmentsResponseMessage.java | 116 +++++++++
 .../FormOutboundStatusListResponseMessage.java     | 102 ++++++++
 ...ormOutboundStatusListRevokeResponseMessage.java |  37 +++
 .../profile/impl/FormStatusListToken.java          | 214 +++++++++++++++++
 .../profile/impl/RevokeStatusListIndex.java        | 129 ++++++++++
 .../profile/impl/SignStatusListToken.java          | 224 ++++++++++++++++++
 .../storage/FeistelIndexPermutation.java           | 122 ++++++++++
 .../statuslist/storage/StatusListAssignment.java   | 183 +++++++++++++++
 .../storage/StatusListAssignmentStore.java         | 261 +++++++++++++++++++++
 .../statuslist/storage/StatusListAssignments.java  | 104 ++++++++
 .../storage/StatusListIndexAllocator.java          | 195 ++++++++++-----
 .../oauth/statuslist/storage/StatusListStore.java  | 242 +++++++++++++++++++
 .../messaging/context/CredentialsContext.java      |  49 ++++
 .../openidvci/profile/impl/AddCredentialShell.java |  89 ++++---
 ...FormJsonLdSelectiveDisclosureJWTCredential.java |   7 +-
 .../plugin/openidvci/profile/impl/ParseProof.java  |   2 +
 .../profile/impl/ResolveCredentialLifetime.java    | 145 ++++++++++++
 .../plugin/openidvci/profile/impl/UnwrapGrant.java |   2 +-
 .../logic/CredentialCountLookupFunction.java       |  58 +++++
 .../logic/CredentialDetailsLookupFunction.java     |  92 ++++++++
 .../logic/CredentialExpirationLookupFunction.java  |  50 ++++
 .../oauth/status-list/assign/assign-beans.xml      |  32 ++-
 .../status-list/assignments/assignments-beans.xml  |  61 +++++
 .../status-list/assignments/assignments-flow.xml   |  26 ++
 .../flows/oauth/status-list/list/list-beans.xml    |  86 +++++++
 .../status-list/list/list-flow.xml}                |   5 +-
 .../oauth/status-list/revoke/revoke-beans.xml      |  65 +++++
 .../flows/oauth/status-list/revoke/revoke-flow.xml |  26 ++
 .../openid/vci/abstract-api/abstract-api-flow.xml  |   2 +-
 .../flows/openid/vci/abstract/abstract-flow.xml    |   3 +-
 .../vci/credential-offer/credential-offer-flow.xml |   2 +-
 .../openid/vci/credentials/credentials-beans.xml   |  15 +-
 .../openid/vci/credentials/credentials-flow.xml    |   9 +-
 .../idp/flows/openid/vci/nonce/nonce-flow.xml      |   2 +-
 .../idp/flows/openid/vci/token/token-flow.xml      |   2 +-
 .../idp/service/relying-party/postconfig.xml       |  12 +-
 .../plugin/openidvci/conf/openid-vci.properties    |  55 ++++-
 .../logic/CredentialIssuerLookupFunctionTest.java  |  68 ++++++
 .../oauth/statuslist/StatusListCodecTest.java      | 162 +++++++++++++
 .../statuslist/profile/impl/AllocateIndexTest.java | 179 +++++++++-----
 .../storage/FeistelIndexPermutationTest.java       | 162 +++++++++++++
 .../storage/StatusListAssignmentStoreTest.java     | 233 ++++++++++++++++++
 .../storage/StatusListIndexAllocatorTest.java      | 164 +++++++++----
 .../statuslist/storage/StatusListStoreTest.java    | 199 ++++++++++++++++
 .../resources/conf/verifiable-credentials.json     |   3 +-
 70 files changed, 5604 insertions(+), 310 deletions(-)

diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/MonthlyBucketStrategy.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/MonthlyBucketStrategy.java
new file mode 100644
index 0000000..5e5de32
--- /dev/null
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/MonthlyBucketStrategy.java
@@ -0,0 +1,83 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.oauth.statuslist;
+
+import java.time.Instant;
+import java.time.YearMonth;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import net.shibboleth.shared.logic.Constraint;
+
+/**
+ * Maps expiration of a credential to a {@link StatusListBucket}, one bucket per
+ * calendar month.
+ *
+ * The bucket is UTC month of expiration and its end is the start of the next
+ * month.
+ */
+public class MonthlyBucketStrategy implements Function<Instant, StatusListBucket> {
+
+    /** Bucket identifier format. */
+    @Nonnull
+    private static final DateTimeFormatter ID_FORMAT = DateTimeFormatter.ofPattern("uuuu-MM");
+
+    /**
+     * Resolve bucket of an identifier.
+     *
+     * @param id identifier to resolve
+     * @return the bucket, or null if the identifier is not a bucket
+     */
+    @Nullable
+    public static StatusListBucket parse(@Nullable final String id) {
+        if (id == null) {
+            return null;
+        }
+        try {
+            return of(YearMonth.parse(id, ID_FORMAT));
+        } catch (final DateTimeParseException e) {
+            return null;
+        }
+    }
+
+    /**
+     * Build bucket of a month.
+     *
+     * @param month month the bucket serves
+     * @return the bucket
+     */
+    @Nonnull
+    private static StatusListBucket of(@Nonnull final YearMonth month) {
+        return new StatusListBucket(month.format(ID_FORMAT),
+                month.plusMonths(1).atDay(1).atStartOfDay(ZoneOffset.UTC).toInstant());
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    @Nonnull
+    public StatusListBucket apply(@Nonnull final Instant expiration) {
+        Constraint.isNotNull(expiration, "Expiration cannot be null");
+
+        return of(YearMonth.from(expiration.atZone(ZoneOffset.UTC)));
+    }
+
+}
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/StatusListIndex.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/StatusListBucket.java
similarity index 57%
rename from openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/StatusListIndex.java
rename to openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/StatusListBucket.java
index 831855a..8a3d844 100644
--- a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/StatusListIndex.java
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/StatusListBucket.java
@@ -16,18 +16,19 @@
 
 package org.geant.shibboleth.plugin.oauth.statuslist;
 
+import java.time.Instant;
+
+import javax.annotation.Nonnull;
+
 /**
- * Immutable value identifying a single slot in a Status List, as in
+ * Immutable identity of one Status List as in
  * <a href="https://datatracker.ietf.org/doc/draft-ietf-oauth-status-list/">
  * draft-ietf-oauth-status-list</a>.
  *
- * {@code uriIndex} identifies which list-generation the slot belongs to; the
- * mapping from {@code uriIndex} to the actual {@code status_list.uri} embedded
- * in issued credentials is the responsibility of the caller.
+ * A bucket holds credentials that all expire before the end of the bucket.
  *
- * @param index    slot within the list (0-based, less than the list's
- *                 configured capacity)
- * @param uriIndex generation of the list this slot belongs to (0-based,
- *                 incremented on rollover)
+ * @param id  identifier, used as storage key
+ * @param end upper bound for expiration of any credential in this bucket
  */
-public record StatusListIndex(long index, long uriIndex) { }
+public record StatusListBucket(@Nonnull String id, @Nonnull Instant end) {
+}
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/StatusListCodec.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/StatusListCodec.java
new file mode 100644
index 0000000..2ca2bc9
--- /dev/null
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/StatusListCodec.java
@@ -0,0 +1,159 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.oauth.statuslist;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.zip.Deflater;
+
+import javax.annotation.Nonnull;
+
+import com.nimbusds.jose.util.Base64URL;
+
+/**
+ * Encoding of a status list as in
+ * <a href="https://datatracker.ietf.org/doc/draft-ietf-oauth-status-list/">
+ * draft-ietf-oauth-status-list</a>.
+ *
+ * A whole list is compressed, this is the 'lst' member of a token. A piece of a
+ * list is stored uncompressed.
+ */
+public final class StatusListCodec {
+
+    /** Constructor. */
+    private StatusListCodec() {
+    }
+
+    /**
+     * Get status list with every slot valid.
+     *
+     * @return uncompressed status list
+     */
+    @Nonnull
+    public static byte[] empty() {
+        return new byte[StatusListParameters.BYTE_LENGTH];
+    }
+
+    /**
+     * Encode status list.
+     *
+     * @param list uncompressed status list
+     * @return encoded status list
+     */
+    @Nonnull
+    public static String encode(@Nonnull final byte[] list) {
+        final Deflater deflater = new Deflater(Deflater.BEST_COMPRESSION);
+        try {
+            deflater.setInput(list);
+            deflater.finish();
+
+            final ByteArrayOutputStream compressed = new ByteArrayOutputStream();
+            final byte[] chunk = new byte[8192];
+            while (!deflater.finished()) {
+                compressed.write(chunk, 0, deflater.deflate(chunk));
+            }
+
+            return Base64URL.encode(compressed.toByteArray()).toString();
+        } finally {
+            deflater.end();
+        }
+    }
+
+    /**
+     * Get a piece of a status list with every slot valid.
+     *
+     * @return uncompressed piece of a status list
+     */
+    @Nonnull
+    public static byte[] emptyChunk() {
+        return new byte[StatusListParameters.CHUNK_BYTE_LENGTH];
+    }
+
+    /**
+     * Encode a piece of a status list for storage. Not compressed.
+     *
+     * @param chunk uncompressed piece of a status list
+     * @return encoded piece
+     */
+    @Nonnull
+    public static String encodeChunk(@Nonnull final byte[] chunk) {
+        return Base64URL.encode(chunk).toString();
+    }
+
+    /**
+     * Decode a piece of a status list.
+     *
+     * @param encoded encoded piece
+     * @return uncompressed piece of a status list
+     * @throws IOException if the value is not a piece of the expected length
+     */
+    @Nonnull
+    public static byte[] decodeChunk(@Nonnull final String encoded) throws IOException {
+        final byte[] chunk = new Base64URL(encoded).decode();
+        if (chunk.length != StatusListParameters.CHUNK_BYTE_LENGTH) {
+            throw new IOException("Status list piece of " + chunk.length + " bytes, expected "
+                    + StatusListParameters.CHUNK_BYTE_LENGTH);
+        }
+        return chunk;
+    }
+
+    /**
+     * Set status of one slot.
+     *
+     * @param list  uncompressed status list, or a piece of one
+     * @param index slot to set
+     * @param valid whether the slot is to read as valid
+     */
+    public static void setStatus(@Nonnull final byte[] list, final long index, final boolean valid) {
+        checkIndex(list, index);
+
+        final int position = (int) (index / 8);
+        final int bit = 1 << (int) (index % 8);
+
+        if (valid) {
+            list[position] &= (byte) ~bit;
+        } else {
+            list[position] |= (byte) bit;
+        }
+    }
+
+    /**
+     * Get whether slot reads as valid.
+     *
+     * @param list  uncompressed status list, or a piece of one
+     * @param index slot to read
+     * @return whether the slot reads as valid
+     */
+    public static boolean isValid(@Nonnull final byte[] list, final long index) {
+        checkIndex(list, index);
+
+        return (list[(int) (index / 8)] & (1 << (int) (index % 8))) == 0;
+    }
+
+    /**
+     * Check slot is within the array addressed.
+     *
+     * @param list  status list, or a piece of one
+     * @param index slot to check
+     */
+    private static void checkIndex(@Nonnull final byte[] list, final long index) {
+        if (index < 0 || index >= (long) list.length * 8) {
+            throw new IndexOutOfBoundsException("Slot " + index + " outside " + list.length * 8 + " slots");
+        }
+    }
+
+}
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/StatusListParameters.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/StatusListParameters.java
new file mode 100644
index 0000000..505d8f2
--- /dev/null
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/StatusListParameters.java
@@ -0,0 +1,102 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.oauth.statuslist;
+
+import java.time.Duration;
+
+import javax.annotation.Nonnull;
+
+/**
+ * Fixed dimensions of a Status List as in
+ * <a href="https://datatracker.ietf.org/doc/draft-ietf-oauth-status-list/">
+ * draft-ietf-oauth-status-list</a>.
+ *
+ * Allocation and publishing use the same values. Changing any of these breaks
+ * credentials already issued.
+ */
+public final class StatusListParameters {
+
+    /** Width in bits of the slot space of a single list. */
+    public static final int INDEX_BITS = 20;
+
+    /** Number of slots in single list. */
+    public static final long CAPACITY = 1L << INDEX_BITS;
+
+    /** Bits describing status of one slot. */
+    public static final int STATUS_BITS = 1;
+
+    /** Length in bytes of the uncompressed status list. */
+    public static final int BYTE_LENGTH = (int) (CAPACITY * STATUS_BITS / 8);
+
+    /**
+     * Number of pieces a list is stored in.
+     *
+     * Revoking a slot rewrites one piece only and no stored value grows to the size
+     * of a fully revoked list. We need chunks as there is maximun field size 'text'
+     * type of sql fieds and we try not to cross it.
+     *
+     * Changing the parameter makes old storage records incompatible.
+     */
+    public static final int CHUNKS = 8;
+
+    /** Length in bytes of one piece of list. */
+    public static final int CHUNK_BYTE_LENGTH = BYTE_LENGTH / CHUNKS;
+
+    /** Number of slots described by one piece of list. */
+    public static final long CHUNK_CAPACITY = CAPACITY / CHUNKS;
+
+    /**
+     * Get piece of a list holding a slot.
+     *
+     * @param index slot to locate
+     * @return piece holding the slot
+     */
+    public static int chunkOf(final long index) {
+        return (int) (index / CHUNK_CAPACITY);
+    }
+
+    /**
+     * Get the position of slot within the piece holding it.
+     *
+     * @param index slot to locate
+     * @return position within the piece
+     */
+    public static long offsetInChunk(final long index) {
+        return index % CHUNK_CAPACITY;
+    }
+
+    /**
+     * Time a list is kept in storage after the bucket has ended. Only for clock
+     * skew at verifiers.
+     */
+    @Nonnull
+    public static final Duration RETENTION = Duration.ofDays(7);
+
+    /**
+     * Query parameter for the list a request is for. Appears in the url of a
+     * credential.
+     */
+    public static final String BUCKET_PARAMETER = "id";
+
+    /** Member for the slot of a list, named as in a credential status reference. */
+    public static final String INDEX_PARAMETER = "idx";
+
+    /** Constructor. */
+    private StatusListParameters() {
+    }
+
+}
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/context/StatusListContext.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/context/StatusListContext.java
index c97f1a9..0030101 100644
--- a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/context/StatusListContext.java
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/context/StatusListContext.java
@@ -16,57 +16,78 @@
 
 package org.geant.shibboleth.plugin.oauth.statuslist.context;
 
+import java.util.List;
+
+import javax.annotation.Nullable;
+
 import org.opensaml.messaging.context.BaseContext;
 
 /**
- * Subcontext for carrying allocated index and path component it's status list
- * url as in
+ * Subcontext for status list slots allocated for the issuance, as in
  * <a href="https://datatracker.ietf.org/doc/draft-ietf-oauth-status-list/">
  * draft-ietf-oauth-status-list</a>. This context appears as a subcontext of the
  * outbound {@link MessageContext}.
  */
 public class StatusListContext extends BaseContext {
 
-    /** Allocated status list index. */
-    private long index;
+    /** Status list url allocated indices belong to. */
+    @Nullable
+    private String statusListUri;
 
-    /** Path component of allocated status list url for allocated index. */
-    private String statusListUrlPath;
+    /** Allocated status list indices, one per credential. */
+    @Nullable
+    private List<Long> indices;
 
     /**
-     * Get allocated status list index.
-     * 
-     * @return Allocated status list index
+     * Get status list url the allocated indices belong to.
+     *
+     * @return Status list url the allocated indices belong to
      */
-    public long getIndex() {
-        return index;
+    @Nullable
+    public String getStatusListUri() {
+        return statusListUri;
     }
 
     /**
-     * Set allocated status list index.
-     * 
-     * @param index Allocated status list index
+     * Set status list url allocated indices belong to.
+     *
+     * @param uri Status list url allocated indices belong to
      */
-    public void setIndex(long index) {
-        this.index = index;
+    public void setStatusListUri(@Nullable final String uri) {
+        statusListUri = uri;
     }
 
     /**
-     * Get Path component of allocated status list url for allocated index.
-     * 
-     * @return path component of allocated status list url for allocated index
+     * Get allocated status list indices, one per credential.
+     *
+     * @return Allocated status list indices, one per credential
      */
-    public String getStatusListUrlPath() {
-        return statusListUrlPath;
+    @Nullable
+    public List<Long> getIndices() {
+        return indices;
     }
 
     /**
-     * Set path component of allocated status list url for allocated index.
-     * 
-     * @param statusListUrlPath Path component of allocated status list url for
-     *                          allocated index
+     * Set allocated status list indices, one per credential.
+     *
+     * @param allocated Allocated status list indices, one per credential
      */
-    public void setStatusListUrlPath(String statusListUrlPath) {
-        this.statusListUrlPath = statusListUrlPath;
+    public void setIndices(@Nullable final List<Long> allocated) {
+        indices = allocated == null ? null : List.copyOf(allocated);
     }
-}
\ No newline at end of file
+
+    /**
+     * Get allocated index of a credential.
+     *
+     * @param position of the credential in this issuance
+     * @return allocated index, or null if there is no index for the position
+     */
+    @Nullable
+    public Long getIndex(final int ordinal) {
+        if (indices == null || ordinal < 0 || ordinal >= indices.size()) {
+            return null;
+        }
+        return indices.get(ordinal);
+    }
+
+}
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/context/StatusListTokenContext.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/context/StatusListTokenContext.java
new file mode 100644
index 0000000..1d0954d
--- /dev/null
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/context/StatusListTokenContext.java
@@ -0,0 +1,79 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.oauth.statuslist.context;
+
+import javax.annotation.Nullable;
+
+import org.opensaml.messaging.context.BaseContext;
+
+import com.nimbusds.jwt.JWTClaimsSet;
+
+/**
+ * Subcontext for the Status List Token to be published, as in
+ * <a href="https://datatracker.ietf.org/doc/draft-ietf-oauth-status-list/">
+ * draft-ietf-oauth-status-list</a>. This context appears as a subcontext of the
+ * outbound {@link MessageContext}.
+ */
+public class StatusListTokenContext extends BaseContext {
+
+    /** Claims of status list token, before signing. */
+    @Nullable
+    private JWTClaimsSet claims;
+
+    /** Signed status list token. */
+    @Nullable
+    private String signedToken;
+
+    /**
+     * Get claims of status list token, before signing.
+     *
+     * @return Claims of status list token, before signing
+     */
+    @Nullable
+    public JWTClaimsSet getClaims() {
+        return claims;
+    }
+
+    /**
+     * Set claims of status list token, before signing.
+     *
+     * @param tokenClaims Claims of the status list token, before signing
+     */
+    public void setClaims(@Nullable final JWTClaimsSet tokenClaims) {
+        claims = tokenClaims;
+    }
+
+    /**
+     * Get signed status list token.
+     *
+     * @return Signed status list token
+     */
+    @Nullable
+    public String getSignedToken() {
+        return signedToken;
+    }
+
+    /**
+     * Set signed status list token.
+     *
+     * @param token Signed status list token
+     */
+    public void setSignedToken(@Nullable final String token) {
+        signedToken = token;
+    }
+
+}
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/messaging/error/StatusListError.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/messaging/error/StatusListError.java
new file mode 100644
index 0000000..61160e9
--- /dev/null
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/messaging/error/StatusListError.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.oauth.statuslist.messaging.error;
+
+import javax.annotation.Nonnull;
+
+import com.nimbusds.oauth2.sdk.ErrorObject;
+
+/**
+ * Status list errors.
+ */
+public final class StatusListError {
+
+    /** No list of that name is published. */
+    @Nonnull
+    public static final ErrorObject UNKNOWN_STATUS_LIST = new ErrorObject("unknown_status_list",
+            "No status list of that name.", 404);
+
+    /**
+     * The list is not published anymore.
+     *
+     * Separate from an unknown list, so that a verifier can tell a retired list
+     * from one that never existed.
+     */
+    @Nonnull
+    public static final ErrorObject RETIRED_STATUS_LIST = new ErrorObject("retired_status_list",
+            "Status list is no longer published, every credential in it having expired.", 410);
+
+    /** Constructor. */
+    private StatusListError() {
+    }
+
+}
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/StatusListEventIds.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/StatusListEventIds.java
new file mode 100644
index 0000000..e06e57d
--- /dev/null
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/StatusListEventIds.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.oauth.statuslist.profile;
+
+import javax.annotation.Nonnull;
+
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+
+/**
+ * Status list event constants.
+ */
+public final class StatusListEventIds {
+
+    /** The requested list is not a list of this issuer. */
+    @Nonnull
+    @NotEmpty
+    public static final String NO_STATUS_LIST = "NoStatusList";
+
+    /** The requested list has ended and no credential of it can be valid. */
+    @Nonnull
+    @NotEmpty
+    public static final String STATUS_LIST_RETIRED = "StatusListRetired";
+
+    /** Constructor. */
+    private StatusListEventIds() {
+    }
+
+}
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialDefinition.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialDefinition.java
index 98b3cef..f867050 100644
--- a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialDefinition.java
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialDefinition.java
@@ -23,9 +23,7 @@ import javax.annotation.Nullable;
 
 import com.fasterxml.jackson.annotation.JsonCreator;
 import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
-import com.fasterxml.jackson.annotation.JsonInclude;
 import com.fasterxml.jackson.annotation.JsonProperty;
-import com.fasterxml.jackson.annotation.JsonInclude.Include;
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.databind.ObjectMapper;
 
@@ -51,34 +49,18 @@ public class CredentialDefinition {
     @JsonProperty("type")
     private final List<String> type;
 
-    /** Issuer in credential definition. */
-    @Nullable
-    @JsonProperty("issuer")
-    @JsonInclude(Include.NON_NULL)
-    private final String issuer;
-
-    /**
-     * 
-     * @return
-     */
-    public String getIssuer() {
-        return issuer;
-    }
-
     /**
      * Constructor.
-     * 
+     *
      * @param context Context of credential definition
-     * @param id      Id of credential definition
      * @param type    Type of credential definition
      */
     private CredentialDefinition(@JsonProperty("@context") @Nonnull List<String> context,
-            @JsonProperty("type") @Nonnull List<String> type, @JsonProperty("issuer") @Nullable String issuer) {
+            @JsonProperty("type") @Nonnull List<String> type) {
         assert context != null;
         assert type != null;
         this.context = context;
         this.type = type;
-        this.issuer = issuer;
     }
 
     /**
@@ -125,11 +107,10 @@ public class CredentialDefinition {
 
     @JsonCreator
     public static CredentialDefinition of(@JsonProperty("@context") @Nullable List<String> context,
-            @JsonProperty("id") @Nullable String id, @JsonProperty("type") @Nullable List<String> type,
-            @JsonProperty("issuer") @Nullable String issuer) {
+            @JsonProperty("id") @Nullable String id, @JsonProperty("type") @Nullable List<String> type) {
         if (context == null || type == null) {
             return null;
         }
-        return new CredentialDefinition(context, type, issuer);
+        return new CredentialDefinition(context, type);
     }
 }
diff --git a/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialConfigurationTest.java b/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialConfigurationTest.java
index 99b33bf..bfaa18c 100644
--- a/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialConfigurationTest.java
+++ b/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialConfigurationTest.java
@@ -164,7 +164,6 @@ public class CredentialConfigurationTest {
         Assert.assertEquals(configuration.getFormat(), "vc+sd-jwt");
         Assert.assertNotNull(configuration.getCredentialDefinition());
         Assert.assertEquals(configuration.getCredentialDefinition().getType().get(1), "IdentityCredential");
-        Assert.assertEquals(configuration.getCredentialDefinition().getIssuer(), "https://issuer.example.org");
         Assert.assertNull(configuration.getVct());
     }
 
diff --git a/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialDefinitionTest.java b/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialDefinitionTest.java
index 2eaedbd..e55aecd 100644
--- a/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialDefinitionTest.java
+++ b/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialDefinitionTest.java
@@ -48,9 +48,8 @@ public class CredentialDefinitionTest {
                 "https://www.w3.org/2018/credentials/examples/v1");
         Assert.assertEquals(credentialDefinition.getType().get(0), "VerifiableCredential");
         Assert.assertEquals(credentialDefinition.getType().get(1), "UniversityDegreeCredential");
-        Assert.assertEquals(credentialDefinition.getIssuer(), "did:jwk:eyJrdHkiOiJFQyIsImNydiI6IlAtMjU2IiwieCI6IlREaW5WM3R5b2VUNEVmVU1Bd25QZnlkbkdzay1qVnczQlFXNldGdnZjRk0iLCJ5IjoiMGplbkx3UG9IbzRfM21Ma2gyMXV5Y0hWTzM3d2hCUTZqd3pDLV9VUXBsdyIsInVzZSI6InNpZyIsImFsZyI6IkVTMjU2In0");
         Assert.assertEquals(credentialDefinition.serialize(),
-                "{\"@context\":[\"https://www.w3.org/2018/credentials/v1\",\"https://www.w3.org/2018/credentials/examples/v1\"],\"type\":[\"VerifiableCredential\",\"UniversityDegreeCredential\"],\"issuer\":\"did:jwk:eyJrdHkiOiJFQyIsImNydiI6IlAtMjU2IiwieCI6IlREaW5WM3R5b2VUNEVmVU1Bd25QZnlkbkdzay1qVnczQlFXNldGdnZjRk0iLCJ5IjoiMGplbkx3UG9IbzRfM21Ma2gyMXV5Y0hWTzM3d2hCUTZqd3pDLV9VUXBsdyIsInVzZSI6InNpZyIsImFsZyI6IkVTMjU2In0\"}");
+                "{\"@context\":[\"https://www.w3.org/2018/credentials/v1\",\"https://www.w3.org/2018/credentials/examples/v1\"],\"type\":[\"VerifiableCredential\",\"UniversityDegreeCredential\"]}");
 
     }
 
diff --git a/openid-vci-api/src/test/resources/credentials/CredentialDefinition.json b/openid-vci-api/src/test/resources/credentials/CredentialDefinition.json
index fb21edf..5a74cd9 100644
--- a/openid-vci-api/src/test/resources/credentials/CredentialDefinition.json
+++ b/openid-vci-api/src/test/resources/credentials/CredentialDefinition.json
@@ -6,6 +6,5 @@
 	"type": [
 		"VerifiableCredential",
 		"UniversityDegreeCredential"
-	],
-	"issuer": "did:jwk:eyJrdHkiOiJFQyIsImNydiI6IlAtMjU2IiwieCI6IlREaW5WM3R5b2VUNEVmVU1Bd25QZnlkbkdzay1qVnczQlFXNldGdnZjRk0iLCJ5IjoiMGplbkx3UG9IbzRfM21Ma2gyMXV5Y0hWTzM3d2hCUTZqd3pDLV9VUXBsdyIsInVzZSI6InNpZyIsImFsZyI6IkVTMjU2In0"
+	]
 }
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/profile/logic/CredentialIssuerLookupFunction.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/profile/logic/CredentialIssuerLookupFunction.java
new file mode 100644
index 0000000..3481ce4
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/profile/logic/CredentialIssuerLookupFunction.java
@@ -0,0 +1,156 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.oauth.profile.logic;
+
+import java.security.PublicKey;
+import java.security.interfaces.ECPublicKey;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.messaging.context.navigate.ChildContextLookup;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.opensaml.profile.context.navigate.OutboundMessageContextLookup;
+import org.opensaml.security.credential.Credential;
+import org.slf4j.Logger;
+
+import com.nimbusds.jose.jwk.Curve;
+import com.nimbusds.jose.jwk.ECKey;
+import com.nimbusds.jose.util.Base64URL;
+
+import net.shibboleth.oidc.security.jose.SignatureSigningParameters;
+import net.shibboleth.oidc.security.jose.context.SecurityParametersContext;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/**
+ * Issuer identifier for everything this plugin signs. By default the
+ * <a href="https://github.com/quartzjer/did-jwk/blob/main/spec.md">did:jwk</a>
+ * of the resolved signing key. Setting an identifier overrides this, that is a
+ * placeholder for future types and this all propably needs additional
+ * modifications at that point.
+ *
+ * TODO: Whole did:jwk and key rotation needs still thinking over. Current
+ * implementation is pretty much stuck with one key.
+ */
+public class CredentialIssuerLookupFunction implements Function<ProfileRequestContext, String> {
+
+    /** Prefix of a did:jwk identifier. */
+    @Nonnull
+    private static final String PREFIX = "did:jwk:";
+
+    /** Class logger. */
+    @Nonnull
+    private final Logger log = LoggerFactory.getLogger(CredentialIssuerLookupFunction.class);
+
+    /** Strategy used to locate {@link SecurityParametersContext} to read. */
+    @Nonnull
+    private Function<ProfileRequestContext, SecurityParametersContext> securityParametersLookupStrategy;
+
+    /** Issuer identifier to use in place of one derived from the signing key. */
+    @Nullable
+    private String configuredIssuer;
+
+    /** Constructor. */
+    public CredentialIssuerLookupFunction() {
+        final Function<ProfileRequestContext, SecurityParametersContext> splcs = new ChildContextLookup<>(
+                SecurityParametersContext.class).compose(new OutboundMessageContextLookup());
+        assert splcs != null;
+        securityParametersLookupStrategy = splcs;
+    }
+
+    /**
+     * Set strategy used to locate the {@link SecurityParametersContext} to read.
+     *
+     * @param strategy lookup strategy
+     */
+    public void setSecurityParametersLookupStrategy(
+            @Nonnull final Function<ProfileRequestContext, SecurityParametersContext> strategy) {
+        securityParametersLookupStrategy = Constraint.isNotNull(strategy,
+                "SecurityParametersContext lookup strategy cannot be null");
+    }
+
+    /**
+     * Set an issuer identifier to use in place of one derived from the signing key.
+     *
+     * Placeholder for future types and this all propably needs additional
+     * modifications at that point.
+     *
+     * @param issuer issuer identifier, or null to derive one
+     */
+    public void setIssuer(@Nullable final String issuer) {
+        if (issuer != null && !issuer.isEmpty()) {
+            configuredIssuer = issuer;
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    @Nullable
+    public String apply(@Nullable final ProfileRequestContext profileRequestContext) {
+        if (configuredIssuer != null) {
+            return configuredIssuer;
+        }
+        if (profileRequestContext == null) {
+            return null;
+        }
+
+        final SecurityParametersContext secParamCtx = securityParametersLookupStrategy.apply(profileRequestContext);
+        if (secParamCtx == null) {
+            log.debug("No security parameters context, unable to derive issuer");
+            return null;
+        }
+
+        final SignatureSigningParameters params = secParamCtx.getSignatureSigningParameters();
+        if (params == null) {
+            log.debug("No signature signing parameters, unable to derive issuer");
+            return null;
+        }
+
+        final Credential credential = params.getSigningCredential();
+        if (credential == null) {
+            log.debug("No signing credential, unable to derive issuer");
+            return null;
+        }
+
+        return toDidJwk(credential.getPublicKey());
+    }
+
+    /**
+     * Encode a public key as a did:jwk identifier.
+     *
+     * @param publicKey key to encode
+     * @return did:jwk identifier, or null if the key is of an unsupported type
+     */
+    @Nullable
+    public String toDidJwk(@Nullable final PublicKey publicKey) {
+        if (!(publicKey instanceof final ECPublicKey ecKey)) {
+            log.error("Signing key is not an EC key, unable to derive a did:jwk issuer");
+            return null;
+        }
+
+        final Curve curve = Curve.forECParameterSpec(ecKey.getParams());
+        if (curve == null) {
+            log.error("Unrecognised EC curve, unable to derive a did:jwk issuer");
+            return null;
+        }
+
+        return PREFIX + Base64URL.encode(new ECKey.Builder(curve, ecKey).build().toJSONString()).toString();
+    }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/config/DefaultStatusListAssignmentsConfiguration.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/config/DefaultStatusListAssignmentsConfiguration.java
new file mode 100644
index 0000000..f9643b5
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/config/DefaultStatusListAssignmentsConfiguration.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.oauth.statuslist.config;
+
+import javax.annotation.Nonnull;
+
+import org.geant.shibboleth.plugin.openidvci.config.impl.AbstractOpenIDVCIConfiguration;
+
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+
+/**
+ * Profile configuration for the endpoint reading out records of status list
+ * assignments.
+ */
+public class DefaultStatusListAssignmentsConfiguration extends AbstractOpenIDVCIConfiguration {
+
+    /** ID for status list assignments profile configuration. */
+    @Nonnull
+    @NotEmpty
+    public static final String PROFILE_ID = "http://geant.org/ns/profiles/oauth/status-list/assignments";
+
+    /** Constructor. */
+    public DefaultStatusListAssignmentsConfiguration() {
+        super(PROFILE_ID);
+    }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/config/DefaultStatusListConfiguration.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/config/DefaultStatusListConfiguration.java
new file mode 100644
index 0000000..589edcb
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/config/DefaultStatusListConfiguration.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.oauth.statuslist.config;
+
+import javax.annotation.Nonnull;
+
+import net.shibboleth.idp.profile.config.AbstractInterceptorAwareProfileConfiguration;
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+
+/**
+ * Profile configuration for the endpoint publishing Status List Tokens, as in
+ * <a href="https://datatracker.ietf.org/doc/draft-ietf-oauth-status-list/">
+ * draft-ietf-oauth-status-list</a>.
+ */
+public class DefaultStatusListConfiguration extends AbstractInterceptorAwareProfileConfiguration {
+
+    /** Status list specification URI. */
+    @Nonnull
+    @NotEmpty
+    public static final String PROTOCOL_URI = "https://datatracker.ietf.org/doc/draft-ietf-oauth-status-list/";
+
+    /** ID for status list profile configuration. */
+    @Nonnull
+    @NotEmpty
+    public static final String PROFILE_ID = "http://geant.org/ns/profiles/oauth/status-list";
+
+    /** Constructor. */
+    public DefaultStatusListConfiguration() {
+        super(PROFILE_ID);
+    }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/config/DefaultStatusListRevokeConfiguration.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/config/DefaultStatusListRevokeConfiguration.java
new file mode 100644
index 0000000..d7e1220
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/config/DefaultStatusListRevokeConfiguration.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.oauth.statuslist.config;
+
+import javax.annotation.Nonnull;
+
+import org.geant.shibboleth.plugin.openidvci.config.impl.AbstractOpenIDVCIConfiguration;
+
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+
+/**
+ * Profile configuration for the endpoint revoking a slot of a status list.
+ *
+ */
+public class DefaultStatusListRevokeConfiguration extends AbstractOpenIDVCIConfiguration {
+
+    /** ID for status list revocation profile configuration. */
+    @Nonnull
+    @NotEmpty
+    public static final String PROFILE_ID = "http://geant.org/ns/profiles/oauth/status-list/revoke";
+
+    /** Constructor. */
+    public DefaultStatusListRevokeConfiguration() {
+        super(PROFILE_ID);
+    }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/decoding/impl/StatusListAssignmentsRequestDecoder.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/decoding/impl/StatusListAssignmentsRequestDecoder.java
new file mode 100644
index 0000000..17156ef
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/decoding/impl/StatusListAssignmentsRequestDecoder.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.oauth.statuslist.decoding.impl;
+
+import java.io.IOException;
+
+import javax.annotation.Nonnull;
+
+import org.geant.shibboleth.plugin.oauth.statuslist.messaging.impl.StatusListAssignmentsRequest;
+import org.geant.shibboleth.plugin.openidvci.decoding.impl.BaseOpenIDVCIRequestDecoder;
+import org.geant.shibboleth.plugin.openidvci.decoding.impl.RequestUtil;
+import org.opensaml.messaging.decoder.MessageDecodingException;
+
+import com.google.common.base.MoreObjects;
+import com.nimbusds.oauth2.sdk.ParseException;
+import com.nimbusds.oauth2.sdk.http.HTTPRequest;
+import com.nimbusds.oauth2.sdk.http.JakartaServletUtils;
+
+/**
+ * Decoder for {@link StatusListAssignmentsRequest}.
+ */
+public class StatusListAssignmentsRequestDecoder
+        extends BaseOpenIDVCIRequestDecoder<StatusListAssignmentsRequest> {
+
+    /** {@inheritDoc} */
+    @Override
+    protected StatusListAssignmentsRequest parseMessage() throws MessageDecodingException {
+        try {
+            final HTTPRequest httpRequest = JakartaServletUtils.createHTTPRequest(getHttpServletRequest());
+            getProtocolMessageLogger().trace("Inbound request {}", RequestUtil.toString(httpRequest));
+            return StatusListAssignmentsRequest.parse(httpRequest);
+        } catch (final ParseException | IOException e) {
+            throw new MessageDecodingException(e);
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected String getMessageToLog(@Nonnull final StatusListAssignmentsRequest message) {
+        return MoreObjects.toStringHelper(this).omitNullValues().add("from", message.getFrom())
+                .add("limit", message.getLimit()).toString();
+    }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/decoding/impl/StatusListRevokeRequestDecoder.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/decoding/impl/StatusListRevokeRequestDecoder.java
new file mode 100644
index 0000000..5bacc74
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/decoding/impl/StatusListRevokeRequestDecoder.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.oauth.statuslist.decoding.impl;
+
+import java.io.IOException;
+
+import javax.annotation.Nonnull;
+
+import org.geant.shibboleth.plugin.oauth.statuslist.StatusListParameters;
+import org.geant.shibboleth.plugin.oauth.statuslist.messaging.impl.StatusListRevokeRequest;
+import org.geant.shibboleth.plugin.openidvci.decoding.impl.BaseOpenIDVCIRequestDecoder;
+import org.geant.shibboleth.plugin.openidvci.decoding.impl.RequestUtil;
+import org.opensaml.messaging.decoder.MessageDecodingException;
+
+import com.google.common.base.MoreObjects;
+import com.nimbusds.oauth2.sdk.ParseException;
+import com.nimbusds.oauth2.sdk.http.HTTPRequest;
+import com.nimbusds.oauth2.sdk.http.JakartaServletUtils;
+
+/**
+ * Decoder for {@link StatusListRevokeRequest}.
+ */
+public class StatusListRevokeRequestDecoder extends BaseOpenIDVCIRequestDecoder<StatusListRevokeRequest> {
+
+    /** {@inheritDoc} */
+    @Override
+    protected StatusListRevokeRequest parseMessage() throws MessageDecodingException {
+        try {
+            final HTTPRequest httpRequest = JakartaServletUtils.createHTTPRequest(getHttpServletRequest());
+            getProtocolMessageLogger().trace("Inbound request {}", RequestUtil.toString(httpRequest));
+            return StatusListRevokeRequest.parse(httpRequest);
+        } catch (final ParseException | IOException e) {
+            throw new MessageDecodingException(e);
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected String getMessageToLog(@Nonnull final StatusListRevokeRequest message) {
+        return MoreObjects.toStringHelper(this).omitNullValues()
+                .add(StatusListParameters.BUCKET_PARAMETER, message.getId())
+                .add(StatusListParameters.INDEX_PARAMETER, message.getIndex()).toString();
+    }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/messaging/impl/StatusListAssignmentsRequest.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/messaging/impl/StatusListAssignmentsRequest.java
new file mode 100644
index 0000000..48e8397
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/messaging/impl/StatusListAssignmentsRequest.java
@@ -0,0 +1,196 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.oauth.statuslist.messaging.impl;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.Map;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.nimbusds.common.contenttype.ContentType;
+import com.nimbusds.oauth2.sdk.AbstractOptionallyIdentifiedRequest;
+import com.nimbusds.oauth2.sdk.OAuth2Error;
+import com.nimbusds.oauth2.sdk.ParseException;
+import com.nimbusds.oauth2.sdk.auth.ClientAuthentication;
+import com.nimbusds.oauth2.sdk.http.HTTPRequest;
+import com.nimbusds.oauth2.sdk.id.ClientID;
+
+/**
+ * Request for a page of records of status list assignments.
+ */
+public class StatusListAssignmentsRequest extends AbstractOptionallyIdentifiedRequest {
+
+    /** Records returned when a request asks for no particular number. */
+    public static final int DEFAULT_LIMIT = 100;
+
+    /** Most records one request may ask for. */
+    public static final int MAX_LIMIT = 1000;
+
+    /** Member naming the position to read from. */
+    @Nonnull
+    private static final String FROM_KEY = "from";
+
+    /** Member naming how many records to return. */
+    @Nonnull
+    private static final String LIMIT_KEY = "limit";
+
+    /** Position to read from. */
+    private long from;
+
+    /** Records to return. */
+    private int limit = DEFAULT_LIMIT;
+
+    /**
+     * Constructor for a client authenticated request.
+     *
+     * @param uri                  endpoint the request was made to
+     * @param clientAuthentication client authentication
+     */
+    protected StatusListAssignmentsRequest(@Nullable final URI uri,
+            @Nonnull final ClientAuthentication clientAuthentication) {
+        super(uri, clientAuthentication);
+    }
+
+    /**
+     * Constructor for a request identifying its client without authenticating.
+     *
+     * @param uri      endpoint the request was made to
+     * @param clientId client identifier
+     */
+    protected StatusListAssignmentsRequest(@Nullable final URI uri, @Nonnull final ClientID clientId) {
+        super(uri, clientId);
+    }
+
+    /**
+     * Get position to read from.
+     *
+     * @return Position to read from
+     */
+    public long getFrom() {
+        return from;
+    }
+
+    /**
+     * Set position to read from.
+     *
+     * @param position Position to read from
+     */
+    private void setFrom(final long position) {
+        from = position;
+    }
+
+    /**
+     * Get records to return.
+     *
+     * @return Records to return
+     */
+    public int getLimit() {
+        return limit;
+    }
+
+    /**
+     * Set records to return.
+     *
+     * @param records Records to return
+     */
+    private void setLimit(final int records) {
+        limit = records;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public HTTPRequest toHTTPRequest() {
+        return null;
+    }
+
+    /**
+     * Parse a request for records of status list assignments.
+     *
+     * @param httpRequest request to parse
+     * @return parsed request
+     * @throws ParseException if the request is not one for records
+     */
+    public static StatusListAssignmentsRequest parse(@Nonnull final HTTPRequest httpRequest) throws ParseException {
+        httpRequest.ensureMethod(HTTPRequest.Method.POST);
+        httpRequest.ensureEntityContentType(ContentType.APPLICATION_JSON);
+
+        final ClientAuthentication clientAuthentication;
+        try {
+            clientAuthentication = ClientAuthentication.parse(httpRequest);
+        } catch (final ParseException e) {
+            throw new ParseException(e.getMessage(),
+                    OAuth2Error.INVALID_REQUEST.appendDescription(": " + e.getMessage()));
+        }
+
+        final Map<String, Object> payload;
+        try {
+            payload = new ObjectMapper().readValue(httpRequest.getQuery(),
+                    new TypeReference<Map<String, Object>>() {
+                    });
+        } catch (final JsonProcessingException e) {
+            throw new ParseException(e.getMessage(),
+                    OAuth2Error.INVALID_REQUEST.appendDescription(": " + e.getMessage()));
+        }
+
+        final URI uri;
+        try {
+            uri = httpRequest.getURL().toURI();
+        } catch (final URISyntaxException e) {
+            throw new ParseException(e.getMessage(), e);
+        }
+
+        final StatusListAssignmentsRequest request;
+        if (clientAuthentication != null) {
+            request = new StatusListAssignmentsRequest(uri, clientAuthentication);
+        } else {
+            final Object clientId = payload.get("client_id");
+            if (!(clientId instanceof String value) || value.isEmpty()) {
+                throw new ParseException("Missing client authentication",
+                        OAuth2Error.INVALID_CLIENT.appendDescription(": no client authentication or client_id"));
+            }
+            request = new StatusListAssignmentsRequest(uri, new ClientID(value));
+        }
+
+        final Object from = payload.get(FROM_KEY);
+        if (from != null) {
+            if (!(from instanceof Number position) || position.longValue() != position.doubleValue()
+                    || position.longValue() < 0) {
+                throw new ParseException("Invalid " + FROM_KEY, OAuth2Error.INVALID_REQUEST
+                        .appendDescription(": '" + FROM_KEY + "' must be a non-negative integer"));
+            }
+            request.setFrom(position.longValue());
+        }
+
+        final Object limit = payload.get(LIMIT_KEY);
+        if (limit != null) {
+            if (!(limit instanceof Number records) || records.longValue() != records.doubleValue()
+                    || records.longValue() < 1 || records.longValue() > MAX_LIMIT) {
+                throw new ParseException("Invalid " + LIMIT_KEY, OAuth2Error.INVALID_REQUEST.appendDescription(
+                        ": '" + LIMIT_KEY + "' must be an integer between 1 and " + MAX_LIMIT));
+            }
+            request.setLimit((int) records.longValue());
+        }
+
+        return request;
+    }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/messaging/impl/StatusListAssignmentsSuccessResponse.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/messaging/impl/StatusListAssignmentsSuccessResponse.java
new file mode 100644
index 0000000..bb6e6cc
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/messaging/impl/StatusListAssignmentsSuccessResponse.java
@@ -0,0 +1,83 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.oauth.statuslist.messaging.impl;
+
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+
+import javax.annotation.Nonnull;
+
+import org.geant.shibboleth.plugin.oauth.statuslist.storage.StatusListAssignments;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.nimbusds.common.contenttype.ContentType;
+import com.nimbusds.oauth2.sdk.SuccessResponse;
+import com.nimbusds.oauth2.sdk.http.HTTPResponse;
+
+/**
+ * Response carrying a page of records of status list assignments.
+ *
+ * The records describe what was issued, so the response is not to be cached.
+ */
+public class StatusListAssignmentsSuccessResponse implements SuccessResponse {
+
+    /** The page of records. */
+    @Nonnull
+    private final StatusListAssignments records;
+
+    /**
+     * Constructor.
+     *
+     * @param page the page of records
+     */
+    public StatusListAssignmentsSuccessResponse(@Nonnull final StatusListAssignments page) {
+        records = page;
+    }
+
+    /**
+     * Get the page of records.
+     *
+     * @return The page of records
+     */
+    @Nonnull
+    public StatusListAssignments getRecords() {
+        return records;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean indicatesSuccess() {
+        return true;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public HTTPResponse toHTTPResponse() {
+        final HTTPResponse httpResponse = new HTTPResponse(HTTPResponse.SC_OK);
+        httpResponse.setEntityContentType(ContentType.APPLICATION_JSON);
+        httpResponse.setCacheControl("no-store");
+        httpResponse.setPragma("no-cache");
+        httpResponse.setHeader("Date", DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now()));
+        try {
+            httpResponse.setContent(records.serialize());
+        } catch (final JsonProcessingException e) {
+            throw new IllegalStateException("Unable to serialize records of status list assignments", e);
+        }
+        return httpResponse;
+    }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/messaging/impl/StatusListRevokeRequest.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/messaging/impl/StatusListRevokeRequest.java
new file mode 100644
index 0000000..88f7ee8
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/messaging/impl/StatusListRevokeRequest.java
@@ -0,0 +1,180 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.oauth.statuslist.messaging.impl;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.Map;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.geant.shibboleth.plugin.oauth.statuslist.StatusListParameters;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.nimbusds.common.contenttype.ContentType;
+import com.nimbusds.oauth2.sdk.AbstractOptionallyIdentifiedRequest;
+import com.nimbusds.oauth2.sdk.OAuth2Error;
+import com.nimbusds.oauth2.sdk.ParseException;
+import com.nimbusds.oauth2.sdk.auth.ClientAuthentication;
+import com.nimbusds.oauth2.sdk.http.HTTPRequest;
+import com.nimbusds.oauth2.sdk.id.ClientID;
+
+/**
+ * Request to revoke one slot of a status list, as in
+ * <a href="https://datatracker.ietf.org/doc/draft-ietf-oauth-status-list/">
+ * draft-ietf-oauth-status-list</a>.
+ */
+public class StatusListRevokeRequest extends AbstractOptionallyIdentifiedRequest {
+
+    /** Identifier of the list holding the slot. */
+    @Nullable
+    private String id;
+
+    /** Slot within the list. */
+    private long index;
+
+    /**
+     * Constructor for a client authenticated request.
+     *
+     * @param uri                  endpoint the request was made to
+     * @param clientAuthentication client authentication
+     */
+    protected StatusListRevokeRequest(@Nullable final URI uri,
+            @Nonnull final ClientAuthentication clientAuthentication) {
+        super(uri, clientAuthentication);
+    }
+
+    /**
+     * Constructor for a request identifying its client without authenticating.
+     *
+     * @param uri      endpoint the request was made to
+     * @param clientId client identifier
+     */
+    protected StatusListRevokeRequest(@Nullable final URI uri, @Nonnull final ClientID clientId) {
+        super(uri, clientId);
+    }
+
+    /**
+     * Get identifier of the list holding the slot.
+     *
+     * @return Identifier of the list holding the slot
+     */
+    @Nullable
+    public String getId() {
+        return id;
+    }
+
+    /**
+     * Set identifier of the list holding the slot.
+     *
+     * @param listId Identifier of the list holding the slot
+     */
+    private void setId(@Nullable final String listId) {
+        id = listId;
+    }
+
+    /**
+     * Get slot within the list.
+     *
+     * @return Slot within the list
+     */
+    public long getIndex() {
+        return index;
+    }
+
+    /**
+     * Set slot within the list.
+     *
+     * @param slot Slot within the list
+     */
+    private void setIndex(final long slot) {
+        index = slot;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public HTTPRequest toHTTPRequest() {
+        return null;
+    }
+
+    /**
+     * Parse a revocation request.
+     *
+     * @param httpRequest request to parse
+     * @return parsed request
+     * @throws ParseException if the request is not a revocation request
+     */
+    public static StatusListRevokeRequest parse(@Nonnull final HTTPRequest httpRequest) throws ParseException {
+        httpRequest.ensureMethod(HTTPRequest.Method.POST);
+        httpRequest.ensureEntityContentType(ContentType.APPLICATION_JSON);
+
+        final ClientAuthentication clientAuthentication;
+        try {
+            clientAuthentication = ClientAuthentication.parse(httpRequest);
+        } catch (final ParseException e) {
+            throw new ParseException(e.getMessage(),
+                    OAuth2Error.INVALID_REQUEST.appendDescription(": " + e.getMessage()));
+        }
+
+        final Map<String, Object> payload;
+        try {
+            payload = new ObjectMapper().readValue(httpRequest.getQuery(), new TypeReference<Map<String, Object>>() {
+            });
+        } catch (final JsonProcessingException e) {
+            throw new ParseException(e.getMessage(),
+                    OAuth2Error.INVALID_REQUEST.appendDescription(": " + e.getMessage()));
+        }
+
+        final URI uri;
+        try {
+            uri = httpRequest.getURL().toURI();
+        } catch (final URISyntaxException e) {
+            throw new ParseException(e.getMessage(), e);
+        }
+
+        final StatusListRevokeRequest request;
+        if (clientAuthentication != null) {
+            request = new StatusListRevokeRequest(uri, clientAuthentication);
+        } else {
+            final Object clientId = payload.get("client_id");
+            if (!(clientId instanceof String value) || value.isEmpty()) {
+                throw new ParseException("Missing client authentication",
+                        OAuth2Error.INVALID_CLIENT.appendDescription(": no client authentication or client_id"));
+            }
+            request = new StatusListRevokeRequest(uri, new ClientID(value));
+        }
+
+        if (!(payload.get(StatusListParameters.BUCKET_PARAMETER) instanceof String listId) || listId.isEmpty()) {
+            throw new ParseException("Missing " + StatusListParameters.BUCKET_PARAMETER, OAuth2Error.INVALID_REQUEST
+                    .appendDescription(": '" + StatusListParameters.BUCKET_PARAMETER + "' must be a string"));
+        }
+        request.setId(listId);
+
+        if (!(payload.get(StatusListParameters.INDEX_PARAMETER) instanceof Number slot)
+                || slot.longValue() != slot.doubleValue()) {
+            throw new ParseException("Missing " + StatusListParameters.INDEX_PARAMETER, OAuth2Error.INVALID_REQUEST
+                    .appendDescription(": '" + StatusListParameters.INDEX_PARAMETER + "' must be an integer"));
+        }
+        request.setIndex(slot.longValue());
+
+        return request;
+    }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/messaging/impl/StatusListRevokeSuccessResponse.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/messaging/impl/StatusListRevokeSuccessResponse.java
new file mode 100644
index 0000000..0321466
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/messaging/impl/StatusListRevokeSuccessResponse.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.oauth.statuslist.messaging.impl;
+
+import com.nimbusds.oauth2.sdk.SuccessResponse;
+import com.nimbusds.oauth2.sdk.http.HTTPResponse;
+
+/**
+ * Response to a successful revocation.
+ */
+public class StatusListRevokeSuccessResponse implements SuccessResponse {
+
+    /** Status of a request that succeeded with nothing to return. */
+    private static final int SC_NO_CONTENT = 204;
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean indicatesSuccess() {
+        return true;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public HTTPResponse toHTTPResponse() {
+        final HTTPResponse httpResponse = new HTTPResponse(SC_NO_CONTENT);
+        httpResponse.setCacheControl("no-store");
+        return httpResponse;
+    }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/messaging/impl/StatusListSuccessResponse.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/messaging/impl/StatusListSuccessResponse.java
new file mode 100644
index 0000000..5192dd8
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/messaging/impl/StatusListSuccessResponse.java
@@ -0,0 +1,94 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.oauth.statuslist.messaging.impl;
+
+import java.text.ParseException;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+
+import javax.annotation.Nonnull;
+
+import com.nimbusds.common.contenttype.ContentType;
+import com.nimbusds.oauth2.sdk.SuccessResponse;
+import com.nimbusds.oauth2.sdk.http.HTTPResponse;
+
+/**
+ * Response carrying a signed Status List Token, as in
+ * <a href="https://datatracker.ietf.org/doc/draft-ietf-oauth-status-list/">
+ * draft-ietf-oauth-status-list</a>.
+ */
+public class StatusListSuccessResponse implements SuccessResponse {
+
+    /** Media type of a status list token. */
+    @Nonnull
+    private static final ContentType CONTENT_TYPE = contentType();
+
+    /** Signed status list token. */
+    @Nonnull
+    private final String token;
+
+    /**
+     * Constructor.
+     *
+     * @param statusListToken signed status list token
+     */
+    public StatusListSuccessResponse(@Nonnull final String statusListToken) {
+        token = statusListToken;
+    }
+
+    /**
+     * Get the signed status list token.
+     *
+     * @return signed status list token in compact serialization
+     */
+    @Nonnull
+    public String getToken() {
+        return token;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean indicatesSuccess() {
+        return true;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public HTTPResponse toHTTPResponse() {
+        final HTTPResponse httpResponse = new HTTPResponse(HTTPResponse.SC_OK);
+        httpResponse.setEntityContentType(CONTENT_TYPE);
+        httpResponse.setHeader("Date", DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now()));
+        // TODO: Cache-Control belongs here
+        httpResponse.setContent(token);
+        return httpResponse;
+    }
+
+    /**
+     * Parse the media type of a status list token.
+     *
+     * @return media type
+     */
+    @Nonnull
+    private static ContentType contentType() {
+        try {
+            return ContentType.parse("application/statuslist+jwt");
+        } catch (final ParseException e) {
+            throw new IllegalStateException("Unable to parse status list media type", e);
+        }
+    }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/AllocateIndex.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/AllocateIndex.java
index 94bc317..ab9c93b 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/AllocateIndex.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/AllocateIndex.java
@@ -17,10 +17,16 @@ package org.geant.shibboleth.plugin.oauth.statuslist.profile.impl;
 import java.io.IOException;
 import java.net.URI;
 import java.net.URISyntaxException;
+import java.time.Instant;
+import java.util.List;
+import java.util.function.Function;
 
 import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
 
-import org.geant.shibboleth.plugin.oauth.statuslist.StatusListIndex;
+import org.geant.shibboleth.plugin.oauth.statuslist.MonthlyBucketStrategy;
+import org.geant.shibboleth.plugin.oauth.statuslist.StatusListBucket;
+import org.geant.shibboleth.plugin.oauth.statuslist.StatusListParameters;
 import org.geant.shibboleth.plugin.oauth.statuslist.context.StatusListContext;
 import org.geant.shibboleth.plugin.oauth.statuslist.storage.StatusListIndexAllocator;
 import org.opensaml.messaging.context.MessageContext;
@@ -37,9 +43,12 @@ import net.shibboleth.shared.logic.Constraint;
 import net.shibboleth.shared.primitive.LoggerFactory;
 
 /**
- * Action that allocates a status list slot via {@link StatusListIndexAllocator}
+ * Action that allocates status list slots via {@link StatusListIndexAllocator}
  * and populates a {@link StatusListContext} on the outbound
  * {@link MessageContext}.
+ *
+ * Bucket is chosen from the expiration of the credentials, list always outlives
+ * the credentials in it.
  */
 public class AllocateIndex extends AbstractProfileAction {
 
@@ -51,15 +60,32 @@ public class AllocateIndex extends AbstractProfileAction {
     @NonnullAfterInit
     private StatusListIndexAllocator statusListAllocator;
 
+    /** Strategy used to obtain the expiration shared by the credentials issued. */
+    @NonnullAfterInit
+    private Function<ProfileRequestContext, Instant> expirationLookupStrategy;
+
+    /** Strategy used to obtain the number of credentials being issued. */
+    @NonnullAfterInit
+    private Function<ProfileRequestContext, Integer> indexCountLookupStrategy;
+
     /**
-     * Path prefixing etc is still a WIP. Minimal implementention to move forward.
-     **/
+     * Strategy used to choose bucket an expiration belongs to.
+     */
+    @Nonnull
+    private final Function<Instant, StatusListBucket> bucketStrategy = new MonthlyBucketStrategy();
+
     /**
-     * Path prefix prepended to the {@code uriIndex} to form the status list URL
-     * path.
+     * Path of endpoint publishing status lists. TODO: Maybe not hadcoded here.
      */
     @Nonnull
-    private String pathPrefix = "/idp/profile/statusList";
+    private String pathPrefix = "/idp/profile/oauth/status-list/list";
+
+    /** Expiration shared by credentials issued. */
+    @Nullable
+    private Instant expiration;
+
+    /** Number of credentials being issued. */
+    private int count;
 
     /**
      * Set the status list index allocator.
@@ -72,7 +98,28 @@ public class AllocateIndex extends AbstractProfileAction {
     }
 
     /**
-     * Set the path prefix prepended to the allocated {@code uriIndex}.
+     * Set the strategy used to obtain the expiration shared by the credentials
+     * issued.
+     *
+     * @param strategy lookup strategy
+     */
+    public void setExpirationLookupStrategy(@Nonnull final Function<ProfileRequestContext, Instant> strategy) {
+        checkSetterPreconditions();
+        expirationLookupStrategy = Constraint.isNotNull(strategy, "Expiration lookup strategy cannot be null");
+    }
+
+    /**
+     * Set the strategy used to obtain the number of credentials being issued.
+     *
+     * @param strategy lookup strategy
+     */
+    public void setIndexCountLookupStrategy(@Nonnull final Function<ProfileRequestContext, Integer> strategy) {
+        checkSetterPreconditions();
+        indexCountLookupStrategy = Constraint.isNotNull(strategy, "Index count lookup strategy cannot be null");
+    }
+
+    /**
+     * Set the path prefix prepended to the bucket identifier.
      *
      * @param prefix Path prefix to use.
      */
@@ -88,6 +135,12 @@ public class AllocateIndex extends AbstractProfileAction {
         if (statusListAllocator == null) {
             throw new ComponentInitializationException("StatusListIndexAllocator cannot be null");
         }
+        if (expirationLookupStrategy == null) {
+            throw new ComponentInitializationException("Expiration lookup strategy cannot be null");
+        }
+        if (indexCountLookupStrategy == null) {
+            throw new ComponentInitializationException("Index count lookup strategy cannot be null");
+        }
     }
 
     /** {@inheritDoc} */
@@ -101,6 +154,22 @@ public class AllocateIndex extends AbstractProfileAction {
             ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
             return false;
         }
+
+        expiration = expirationLookupStrategy.apply(profileRequestContext);
+        if (expiration == null) {
+            log.error("{} Unable to determine credential expiration to choose a status list", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+            return false;
+        }
+
+        final Integer requested = indexCountLookupStrategy.apply(profileRequestContext);
+        if (requested == null || requested < 1) {
+            log.error("{} Unable to determine how many status list slots to allocate", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+            return false;
+        }
+        count = requested;
+
         return true;
     }
 
@@ -115,11 +184,14 @@ public class AllocateIndex extends AbstractProfileAction {
             return;
         }
 
-        final StatusListIndex slot;
+        assert expiration != null;
+        final StatusListBucket bucket = bucketStrategy.apply(expiration);
+
+        final List<Long> indices;
         try {
-            slot = statusListAllocator.allocate();
+            indices = statusListAllocator.allocate(bucket, count);
         } catch (final IOException e) {
-            log.error("{} Failed to allocate status list slot: {}", getLogPrefix(), e.getMessage());
+            log.error("{} Failed to allocate status list slots: {}", getLogPrefix(), e.getMessage());
             ActionSupport.buildEvent(profileRequestContext, EventIds.IO_ERROR);
             return;
         }
@@ -131,8 +203,8 @@ public class AllocateIndex extends AbstractProfileAction {
             assert scheme != null;
             final String serverName = request.getServerName();
             assert serverName != null;
-            statusListUri = buildURIIgnoreDefaultPorts(scheme, serverName, request.getServerPort(),
-                    pathPrefix + "/" + slot.uriIndex());
+            statusListUri = buildURIIgnoreDefaultPorts(scheme, serverName, request.getServerPort(), pathPrefix,
+                    StatusListParameters.BUCKET_PARAMETER + "=" + bucket.id());
         } catch (final URISyntaxException e) {
             log.error("{} Unable to build status list URI", getLogPrefix(), e);
             ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
@@ -140,11 +212,11 @@ public class AllocateIndex extends AbstractProfileAction {
         }
 
         final StatusListContext ctx = outbound.ensureSubcontext(StatusListContext.class);
-        ctx.setIndex(slot.index());
-        ctx.setStatusListUrlPath(statusListUri.toString());
+        ctx.setStatusListUri(statusListUri.toString());
+        ctx.setIndices(indices);
 
-        log.debug("{} Allocated status list slot uriIndex={} index={} uri={}", getLogPrefix(), slot.uriIndex(),
-                slot.index(), statusListUri);
+        log.debug("{} Allocated {} status list slot(s) {} from bucket {} (retirable after {}) at uri {}",
+                getLogPrefix(), count, indices, bucket.id(), bucket.end(), statusListUri);
     }
 
     /**
@@ -155,14 +227,15 @@ public class AllocateIndex extends AbstractProfileAction {
      * @param host   host name
      * @param port   server port
      * @param path   path component
+     * @param query  query component
      * @return constructed URI
      * @throws URISyntaxException if the components do not form a valid URI
      */
     @Nonnull
     private URI buildURIIgnoreDefaultPorts(@Nonnull final String scheme, @Nonnull final String host, final int port,
-            @Nonnull final String path) throws URISyntaxException {
+            @Nonnull final String path, @Nonnull final String query) throws URISyntaxException {
         final boolean defaultPort = ("https".equals(scheme) && port == 443) || ("http".equals(scheme) && port == 80);
-        return new URI(scheme, null, host, defaultPort ? -1 : port, path, null, null);
+        return new URI(scheme, null, host, defaultPort ? -1 : port, path, query, null);
     }
 
 }
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/AssignIndex.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/AssignIndex.java
new file mode 100644
index 0000000..d418df3
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/AssignIndex.java
@@ -0,0 +1,167 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.oauth.statuslist.profile.impl;
+
+import java.io.IOException;
+import java.time.Instant;
+import java.util.Map;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import net.shibboleth.idp.profile.AbstractProfileAction;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+import org.geant.shibboleth.plugin.oauth.statuslist.MonthlyBucketStrategy;
+import org.geant.shibboleth.plugin.oauth.statuslist.context.StatusListContext;
+import org.geant.shibboleth.plugin.oauth.statuslist.storage.StatusListAssignment;
+import org.geant.shibboleth.plugin.oauth.statuslist.storage.StatusListAssignmentStore;
+import org.opensaml.profile.action.ActionSupport;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+/**
+ * Action that records the slots allocated by {@link AllocateIndex}, to be collected
+ * by a party outside the IdP.
+ *
+ * Failing to write the record fails the issuance. 
+ */
+public class AssignIndex extends AbstractProfileAction {
+
+    /** Class logger. */
+    @Nonnull
+    private Logger log = LoggerFactory.getLogger(AssignIndex.class);
+
+    /** Store handing records to a collector. */
+    @NonnullAfterInit
+    private StatusListAssignmentStore statusListAssignmentStore;
+
+    /** Strategy used to obtain the expiration of what took the slots. */
+    @NonnullAfterInit
+    private Function<ProfileRequestContext, Instant> expirationLookupStrategy;
+
+    /** Strategy used to obtain a description of what took the slots. */
+    @NonnullAfterInit
+    private Function<ProfileRequestContext, Map<String, Object>> detailsLookupStrategy;
+
+    /** Status list context. */
+    @NonnullBeforeExec
+    private StatusListContext statusListContext;
+
+    /** Expiration of what took the slots. */
+    @Nullable
+    private Instant expiration;
+
+    /**
+     * Set the store handing records to a collector.
+     *
+     * @param store The store to use.
+     */
+    public void setStatusListAssignmentStore(@Nonnull final StatusListAssignmentStore store) {
+        checkSetterPreconditions();
+        statusListAssignmentStore = Constraint.isNotNull(store, "StatusListAssignmentStore cannot be null");
+    }
+
+    /**
+     * Set the strategy used to obtain the expiration of what took slots.
+     *
+     * @param strategy lookup strategy
+     */
+    public void setExpirationLookupStrategy(@Nonnull final Function<ProfileRequestContext, Instant> strategy) {
+        checkSetterPreconditions();
+        expirationLookupStrategy = Constraint.isNotNull(strategy, "Expiration lookup strategy cannot be null");
+    }
+
+    /**
+     * Set the strategy used to obtain a description of what took slots.
+     *
+     * @param strategy lookup strategy
+     */
+    public void setDetailsLookupStrategy(
+            @Nonnull final Function<ProfileRequestContext, Map<String, Object>> strategy) {
+        checkSetterPreconditions();
+        detailsLookupStrategy = Constraint.isNotNull(strategy, "Details lookup strategy cannot be null");
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
+        if (statusListAssignmentStore == null) {
+            throw new ComponentInitializationException("StatusListAssignmentStore cannot be null");
+        }
+        if (expirationLookupStrategy == null) {
+            throw new ComponentInitializationException("Expiration lookup strategy cannot be null");
+        }
+        if (detailsLookupStrategy == null) {
+            throw new ComponentInitializationException("Details lookup strategy cannot be null");
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+        if (!super.doPreExecute(profileRequestContext)) {
+            return false;
+        }
+
+        if (profileRequestContext.getOutboundMessageContext() != null) {
+            statusListContext = profileRequestContext.getOutboundMessageContext()
+                    .getSubcontext(StatusListContext.class);
+        }
+        if (statusListContext == null || statusListContext.getIndices() == null) {
+            log.error("{} No allocated slots to record", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+            return false;
+        }
+
+        expiration = expirationLookupStrategy.apply(profileRequestContext);
+        if (expiration == null) {
+            log.error("{} Unable to determine the expiration of what took the slots", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+            return false;
+        }
+
+        return true;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+        assert expiration != null;
+
+        final StatusListAssignment record = new StatusListAssignment();
+        record.setId(new MonthlyBucketStrategy().apply(expiration).id());
+        record.setIndices(statusListContext.getIndices());
+        record.setExpires(expiration.getEpochSecond());
+        record.setDetails(detailsLookupStrategy.apply(profileRequestContext));
+
+        try {
+            statusListAssignmentStore.store(record);
+        } catch (final IOException e) {
+            log.error("{} Unable to record the assignment, failing the request: {}", getLogPrefix(), e.getMessage());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.IO_ERROR);
+        }
+    }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/FormOutboundStatusListAssignmentsResponseMessage.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/FormOutboundStatusListAssignmentsResponseMessage.java
new file mode 100644
index 0000000..8ae8607
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/FormOutboundStatusListAssignmentsResponseMessage.java
@@ -0,0 +1,116 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.oauth.statuslist.profile.impl;
+
+import java.io.IOException;
+
+import javax.annotation.Nonnull;
+
+import net.shibboleth.idp.profile.AbstractProfileAction;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+import org.geant.shibboleth.plugin.oauth.statuslist.messaging.impl.StatusListAssignmentsRequest;
+import org.geant.shibboleth.plugin.oauth.statuslist.messaging.impl.StatusListAssignmentsSuccessResponse;
+import org.geant.shibboleth.plugin.oauth.statuslist.storage.StatusListAssignmentStore;
+import org.geant.shibboleth.plugin.oauth.statuslist.storage.StatusListAssignments;
+import org.opensaml.profile.action.ActionSupport;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+/**
+ * Action reading a page of records of status list assignments and forming
+ * {@link StatusListAssignmentsSuccessResponse}.
+ *
+ */
+public class FormOutboundStatusListAssignmentsResponseMessage extends AbstractProfileAction {
+
+    /** Class logger. */
+    @Nonnull
+    private Logger log = LoggerFactory.getLogger(FormOutboundStatusListAssignmentsResponseMessage.class);
+
+    /** Store holding the records. */
+    @NonnullAfterInit
+    private StatusListAssignmentStore statusListAssignmentStore;
+
+    /** Request being served. */
+    @NonnullBeforeExec
+    private StatusListAssignmentsRequest request;
+
+    /**
+     * Set store holding the records.
+     *
+     * @param store store to use.
+     */
+    public void setStatusListAssignmentStore(@Nonnull final StatusListAssignmentStore store) {
+        checkSetterPreconditions();
+        statusListAssignmentStore = Constraint.isNotNull(store, "StatusListAssignmentStore cannot be null");
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
+        if (statusListAssignmentStore == null) {
+            throw new ComponentInitializationException("StatusListAssignmentStore cannot be null");
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+        if (!super.doPreExecute(profileRequestContext)) {
+            return false;
+        }
+
+        if (profileRequestContext.getInboundMessageContext() == null || !(profileRequestContext
+                .getInboundMessageContext().getMessage() instanceof final StatusListAssignmentsRequest message)) {
+            log.error("{} No StatusListAssignmentsRequest as inbound message", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_MSG_CTX);
+            return false;
+        }
+        request = message;
+
+        return true;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+        assert request != null;
+
+        final StatusListAssignments records;
+        try {
+            records = statusListAssignmentStore.read(request.getFrom(), request.getLimit());
+        } catch (final IOException e) {
+            log.error("{} Unable to read records of status list assignments: {}", getLogPrefix(), e.getMessage());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.IO_ERROR);
+            return;
+        }
+
+        profileRequestContext.ensureOutboundMessageContext()
+                .setMessage(new StatusListAssignmentsSuccessResponse(records));
+
+        log.debug("{} Returning {} record(s), continuing at {} of {}", getLogPrefix(), records.getRecords().size(),
+                records.getNext(), records.getHighWater());
+    }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/FormOutboundStatusListResponseMessage.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/FormOutboundStatusListResponseMessage.java
new file mode 100644
index 0000000..4ecd2ea
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/FormOutboundStatusListResponseMessage.java
@@ -0,0 +1,102 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.oauth.statuslist.profile.impl;
+
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+
+import org.geant.shibboleth.plugin.oauth.statuslist.context.StatusListTokenContext;
+import org.geant.shibboleth.plugin.oauth.statuslist.messaging.impl.StatusListSuccessResponse;
+import org.opensaml.messaging.context.navigate.ChildContextLookup;
+import org.opensaml.profile.action.ActionSupport;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.opensaml.profile.context.navigate.OutboundMessageContextLookup;
+import org.slf4j.Logger;
+
+import net.shibboleth.idp.profile.AbstractProfileAction;
+import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/**
+ * Action forming {@link StatusListSuccessResponse} from the signed token in
+ * {@link StatusListTokenContext#getSignedToken}.
+ */
+public class FormOutboundStatusListResponseMessage extends AbstractProfileAction {
+
+    /** Class logger. */
+    @Nonnull
+    private Logger log = LoggerFactory.getLogger(FormOutboundStatusListResponseMessage.class);
+
+    /** Status list token context. */
+    @NonnullBeforeExec
+    private StatusListTokenContext ctx;
+
+    /** Strategy used to locate the status list token context. */
+    @Nonnull
+    private Function<ProfileRequestContext, StatusListTokenContext> statusListTokenContextLookupStrategy;
+
+    /** Constructor. */
+    public FormOutboundStatusListResponseMessage() {
+        final Function<ProfileRequestContext, StatusListTokenContext> tlcs = new ChildContextLookup<>(
+                StatusListTokenContext.class).compose(new OutboundMessageContextLookup());
+        assert tlcs != null;
+        statusListTokenContextLookupStrategy = tlcs;
+    }
+
+    /**
+     * Set the strategy used to locate the {@link StatusListTokenContext} to use.
+     *
+     * @param strategy lookup strategy
+     */
+    public void setStatusListTokenContextLookupStrategy(
+            @Nonnull final Function<ProfileRequestContext, StatusListTokenContext> strategy) {
+        checkSetterPreconditions();
+        statusListTokenContextLookupStrategy = Constraint.isNotNull(strategy,
+                "StatusListTokenContext lookup strategy cannot be null");
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+
+        if (!super.doPreExecute(profileRequestContext)) {
+            return false;
+        }
+
+        ctx = statusListTokenContextLookupStrategy.apply(profileRequestContext);
+        if (ctx == null || ctx.getSignedToken() == null) {
+            log.error("{} No signed status list token to return", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+            return false;
+        }
+
+        return true;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+        profileRequestContext.ensureOutboundMessageContext()
+                .setMessage(new StatusListSuccessResponse(ctx.getSignedToken()));
+
+        log.debug("{} Returning a status list token of {} characters", getLogPrefix(), ctx.getSignedToken().length());
+    }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/FormOutboundStatusListRevokeResponseMessage.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/FormOutboundStatusListRevokeResponseMessage.java
new file mode 100644
index 0000000..2c08bdd
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/FormOutboundStatusListRevokeResponseMessage.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.oauth.statuslist.profile.impl;
+
+import javax.annotation.Nonnull;
+
+import org.geant.shibboleth.plugin.oauth.statuslist.messaging.impl.StatusListRevokeSuccessResponse;
+import org.opensaml.profile.context.ProfileRequestContext;
+
+import net.shibboleth.idp.profile.AbstractProfileAction;
+
+/**
+ * Action forming {@link StatusListRevokeSuccessResponse}.
+ */
+public class FormOutboundStatusListRevokeResponseMessage extends AbstractProfileAction {
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+        profileRequestContext.ensureOutboundMessageContext().setMessage(new StatusListRevokeSuccessResponse());
+    }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/FormStatusListToken.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/FormStatusListToken.java
new file mode 100644
index 0000000..4a6256b
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/FormStatusListToken.java
@@ -0,0 +1,214 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.oauth.statuslist.profile.impl;
+
+import java.io.IOException;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Date;
+import java.util.Map;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.geant.shibboleth.plugin.oauth.statuslist.MonthlyBucketStrategy;
+import org.geant.shibboleth.plugin.oauth.statuslist.StatusListBucket;
+import org.geant.shibboleth.plugin.oauth.statuslist.StatusListCodec;
+import org.geant.shibboleth.plugin.oauth.statuslist.StatusListParameters;
+import org.geant.shibboleth.plugin.oauth.statuslist.context.StatusListTokenContext;
+import org.geant.shibboleth.plugin.oauth.statuslist.profile.StatusListEventIds;
+import org.geant.shibboleth.plugin.oauth.statuslist.storage.StatusListStore;
+import org.opensaml.messaging.context.MessageContext;
+import org.opensaml.profile.action.ActionSupport;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import com.nimbusds.jwt.JWTClaimsSet;
+
+import net.shibboleth.idp.profile.AbstractProfileAction;
+import net.shibboleth.idp.profile.IdPEventIds;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+import org.geant.shibboleth.plugin.oauth.profile.logic.CredentialIssuerLookupFunction;
+
+/**
+ * Action that forms claims of a Status List Token and sets them to
+ * {@link StatusListTokenContext} located under
+ * {@link ProfileRequestContext#getOutboundMessageContext()}, for a subsequent
+ * action to sign.
+ */
+public class FormStatusListToken extends AbstractProfileAction {
+
+    /** Class logger. */
+    @Nonnull
+    private Logger log = LoggerFactory.getLogger(FormStatusListToken.class);
+
+    /** Strategy used to obtain the issuer value. */
+    @Nonnull
+    private Function<ProfileRequestContext, String> issuerLookupStrategy;
+
+    /** Store of the published status lists. */
+    @NonnullAfterInit
+    private StatusListStore statusListStore;
+
+    /** How long a published list may be cached. */
+    @Nonnull
+    private Duration ttl = Duration.ofHours(1);
+
+    /** Compressed and encoded status list with no slot revoked. */
+    @NonnullAfterInit
+    private String emptyList;
+
+    /** Issuer of the token. */
+    @Nullable
+    private String issuerId;
+
+    /** The list being published. */
+    @Nullable
+    private StatusListBucket bucket;
+
+    /** Constructor. */
+    public FormStatusListToken() {
+        // TODO: Token must name the same issuer as the credentials referring to it.
+        // With did:jwk key rollover will need us to redesign things.
+        issuerLookupStrategy = new CredentialIssuerLookupFunction();
+    }
+
+    /**
+     * Set the strategy used to locate the issuer value to use.
+     *
+     * @param strategy lookup strategy
+     */
+    public void setIssuerLookupStrategy(@Nonnull final Function<ProfileRequestContext, String> strategy) {
+        checkSetterPreconditions();
+        issuerLookupStrategy = Constraint.isNotNull(strategy, "IssuerLookupStrategy cannot be null");
+    }
+
+    /**
+     * Set the store of the published status lists.
+     *
+     * @param store The store to use.
+     */
+    public void setStatusListStore(@Nonnull final StatusListStore store) {
+        checkSetterPreconditions();
+        statusListStore = Constraint.isNotNull(store, "StatusListStore cannot be null");
+    }
+
+    /**
+     * Set how long a published list may be cached.
+     *
+     * Also the delay before a revocation is noticed. A verifier with a cached list
+     * does not see it before the cache expires.
+     *
+     * @param cacheLifetime positive lifetime
+     */
+    public void setTtl(@Nonnull final Duration cacheLifetime) {
+        checkSetterPreconditions();
+        Constraint.isNotNull(cacheLifetime, "Ttl cannot be null");
+        Constraint.isFalse(cacheLifetime.isNegative() || cacheLifetime.isZero(), "Ttl must be positive");
+        ttl = cacheLifetime;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
+        if (statusListStore == null) {
+            throw new ComponentInitializationException("StatusListStore cannot be null");
+        }
+        emptyList = StatusListCodec.encode(StatusListCodec.empty());
+        log.debug("{} Status list of {} slots with none revoked encodes to {} characters", getLogPrefix(),
+                StatusListParameters.CAPACITY, emptyList.length());
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+        if (!super.doPreExecute(profileRequestContext)) {
+            return false;
+        }
+        if (getHttpServletRequest() == null) {
+            log.error("{} Profile action does not contain an HttpServletRequest", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+            return false;
+        }
+        if (profileRequestContext.getOutboundMessageContext() == null) {
+            log.error("{} No outbound message context", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+            return false;
+        }
+
+        issuerId = issuerLookupStrategy.apply(profileRequestContext);
+        if (issuerId == null) {
+            log.error("{} Unable to determine issuer of the status list token", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, IdPEventIds.INVALID_PROFILE_CONFIG);
+            return false;
+        }
+
+        final String bucketId = getHttpServletRequest().getParameter(StatusListParameters.BUCKET_PARAMETER);
+        bucket = MonthlyBucketStrategy.parse(bucketId);
+        if (bucket == null) {
+            log.error("{} '{}' in parameter '{}' does not name a status list", getLogPrefix(), bucketId,
+                    StatusListParameters.BUCKET_PARAMETER);
+            ActionSupport.buildEvent(profileRequestContext, StatusListEventIds.NO_STATUS_LIST);
+            return false;
+        }
+        if (!bucket.end().isAfter(Instant.now())) {
+            log.info("{} Status list {} ended at {} and is no longer published", getLogPrefix(), bucket.id(),
+                    bucket.end());
+            ActionSupport.buildEvent(profileRequestContext, StatusListEventIds.STATUS_LIST_RETIRED);
+            return false;
+        }
+
+        return true;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+
+        assert bucket != null;
+
+        final String list;
+        try {
+            final String stored = statusListStore.read(bucket);
+            list = stored == null ? emptyList : stored;
+        } catch (final IOException e) {
+            log.error("{} Unable to read status list {}: {}", getLogPrefix(), bucket.id(), e.getMessage());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.IO_ERROR);
+            return;
+        }
+        final String subject = getHttpServletRequest().getRequestURL().append('?')
+                .append(StatusListParameters.BUCKET_PARAMETER).append('=').append(bucket.id()).toString();
+        final JWTClaimsSet claims = new JWTClaimsSet.Builder().issuer(issuerId).subject(subject)
+                .issueTime(Date.from(Instant.now())).expirationTime(Date.from(bucket.end()))
+                .claim("ttl", ttl.toSeconds())
+                .claim("status_list", Map.of("bits", StatusListParameters.STATUS_BITS, "lst", list)).build();
+
+        final MessageContext outbound = profileRequestContext.getOutboundMessageContext();
+        assert outbound != null;
+        outbound.ensureSubcontext(StatusListTokenContext.class).setClaims(claims);
+
+        log.debug("{} Formed status list token for {}", getLogPrefix(), subject);
+    }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/RevokeStatusListIndex.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/RevokeStatusListIndex.java
new file mode 100644
index 0000000..09ba87a
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/RevokeStatusListIndex.java
@@ -0,0 +1,129 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.oauth.statuslist.profile.impl;
+
+import java.io.IOException;
+
+import javax.annotation.Nonnull;
+
+import org.geant.shibboleth.plugin.oauth.statuslist.MonthlyBucketStrategy;
+import org.geant.shibboleth.plugin.oauth.statuslist.StatusListBucket;
+import org.geant.shibboleth.plugin.oauth.statuslist.StatusListParameters;
+import org.geant.shibboleth.plugin.oauth.statuslist.messaging.impl.StatusListRevokeRequest;
+import org.geant.shibboleth.plugin.oauth.statuslist.storage.StatusListStore;
+import org.opensaml.profile.action.ActionSupport;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import net.shibboleth.idp.profile.AbstractProfileAction;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/**
+ * Action that revokes the slot of a {@link StatusListRevokeRequest} in the
+ * stored status list.
+ *
+ * List is created at the first revocation. Revoking already revoked slot
+ * succeeds without writing.
+ *
+ */
+public class RevokeStatusListIndex extends AbstractProfileAction {
+
+    /** Class logger. */
+    @Nonnull
+    private Logger log = LoggerFactory.getLogger(RevokeStatusListIndex.class);
+
+    /** Store of the published status lists. */
+    @NonnullAfterInit
+    private StatusListStore statusListStore;
+
+    /** The list the slot belongs to. */
+    @NonnullBeforeExec
+    private StatusListBucket bucket;
+
+    /** The slot to revoke. */
+    private long index;
+
+    /**
+     * Set the store of the published status lists.
+     *
+     * @param store The store to use.
+     */
+    public void setStatusListStore(@Nonnull final StatusListStore store) {
+        checkSetterPreconditions();
+        statusListStore = Constraint.isNotNull(store, "StatusListStore cannot be null");
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
+        if (statusListStore == null) {
+            throw new ComponentInitializationException("StatusListStore cannot be null");
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+        if (!super.doPreExecute(profileRequestContext)) {
+            return false;
+        }
+
+        if (profileRequestContext.getInboundMessageContext() == null || !(profileRequestContext
+                .getInboundMessageContext().getMessage() instanceof final StatusListRevokeRequest request)) {
+            log.error("{} No StatusListRevokeRequest as inbound message", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_MSG_CTX);
+            return false;
+        }
+
+        bucket = MonthlyBucketStrategy.parse(request.getId());
+        if (bucket == null) {
+            log.error("{} '{}' does not name a status list", getLogPrefix(), request.getId());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_MSG_CTX);
+            return false;
+        }
+
+        index = request.getIndex();
+        if (index < 0 || index >= StatusListParameters.CAPACITY) {
+            log.error("{} Slot {} lies outside a status list of {}", getLogPrefix(), index,
+                    StatusListParameters.CAPACITY);
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_MSG_CTX);
+            return false;
+        }
+
+        return true;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+        assert bucket != null;
+        try {
+            statusListStore.revoke(bucket, index);
+        } catch (final IOException e) {
+            log.error("{} Failed to revoke slot {} of status list {}: {}", getLogPrefix(), index, bucket.id(),
+                    e.getMessage());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.IO_ERROR);
+        }
+    }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/SignStatusListToken.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/SignStatusListToken.java
new file mode 100644
index 0000000..ce21ffb
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/SignStatusListToken.java
@@ -0,0 +1,224 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.oauth.statuslist.profile.impl;
+
+import java.security.interfaces.ECPrivateKey;
+import java.util.List;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.geant.shibboleth.plugin.oauth.statuslist.context.StatusListTokenContext;
+import org.opensaml.messaging.context.navigate.ChildContextLookup;
+import org.opensaml.profile.action.ActionSupport;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.opensaml.profile.context.navigate.OutboundMessageContextLookup;
+import org.opensaml.security.credential.Credential;
+import org.slf4j.Logger;
+
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.crypto.ECDSASigner;
+import com.nimbusds.jose.util.Base64;
+import com.nimbusds.jwt.SignedJWT;
+
+import net.shibboleth.idp.profile.AbstractProfileAction;
+import net.shibboleth.oidc.security.CredentialConversionUtil;
+import net.shibboleth.oidc.security.jose.SignatureSigningParameters;
+import net.shibboleth.oidc.security.jose.context.SecurityParametersContext;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.logic.FunctionSupport;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/**
+ * Action that signs claims in {@link StatusListTokenContext#getClaims} and
+ * stores the result to {@link StatusListTokenContext#setSignedToken}.
+ *
+ * Same as SignJWTCredential of the credentials flow, including support only for
+ * EC family only. Sets the statuslist+jwt type header required of the token.
+ */
+public class SignStatusListToken extends AbstractProfileAction {
+
+    /** Type header of a status list token. */
+    @Nonnull
+    private static final String TOKEN_TYPE = "statuslist+jwt";
+
+    /** Class logger. */
+    @Nonnull
+    private Logger log = LoggerFactory.getLogger(SignStatusListToken.class);
+
+    /** Status list token context. */
+    @NonnullBeforeExec
+    private StatusListTokenContext ctx;
+
+    /**
+     * Strategy used to locate the {@link SecurityParametersContext} to use for
+     * signing.
+     */
+    @Nonnull
+    private Function<ProfileRequestContext, SecurityParametersContext> securityParametersLookupStrategy;
+
+    /** Strategy used to obtain the certificate chain for signing key. */
+    @Nonnull
+    private Function<SignatureSigningParameters, List<Base64>> certificateChainLookupStrategy;
+
+    /** Strategy used to locate the status list token context. */
+    @Nonnull
+    private Function<ProfileRequestContext, StatusListTokenContext> statusListTokenContextLookupStrategy;
+
+    /** The signature signing parameters. */
+    @NonnullAfterInit
+    private SignatureSigningParameters signatureSigningParameters;
+
+    /** Signing credential. */
+    @NonnullAfterInit
+    private Credential signingCredential;
+
+    /** EC private key for signing. */
+    @NonnullAfterInit
+    private ECPrivateKey privateKey;
+
+    /** Constructor. */
+    public SignStatusListToken() {
+        final Function<ProfileRequestContext, SecurityParametersContext> splcs = new ChildContextLookup<>(
+                SecurityParametersContext.class).compose(new OutboundMessageContextLookup());
+        assert splcs != null;
+        securityParametersLookupStrategy = splcs;
+
+        final Function<ProfileRequestContext, StatusListTokenContext> tlcs = new ChildContextLookup<>(
+                StatusListTokenContext.class).compose(new OutboundMessageContextLookup());
+        assert tlcs != null;
+        statusListTokenContextLookupStrategy = tlcs;
+
+        certificateChainLookupStrategy = FunctionSupport.constant(null);
+    }
+
+    /**
+     * Set the strategy used to locate the {@link SecurityParametersContext} to use.
+     *
+     * @param strategy lookup strategy
+     */
+    public void setSecurityParametersLookupStrategy(
+            @Nonnull final Function<ProfileRequestContext, SecurityParametersContext> strategy) {
+        checkSetterPreconditions();
+        securityParametersLookupStrategy = Constraint.isNotNull(strategy,
+                "SecurityParameterContext lookup strategy cannot be null");
+    }
+
+    /**
+     * Set the strategy used to locate the {@link StatusListTokenContext} to use.
+     *
+     * @param strategy lookup strategy
+     */
+    public void setStatusListTokenContextLookupStrategy(
+            @Nonnull final Function<ProfileRequestContext, StatusListTokenContext> strategy) {
+        checkSetterPreconditions();
+        statusListTokenContextLookupStrategy = Constraint.isNotNull(strategy,
+                "StatusListTokenContext lookup strategy cannot be null");
+    }
+
+    /**
+     * Set the strategy used to obtain the certificate chain for the signing key.
+     *
+     * @param strategy lookup strategy
+     */
+    public void setCertificateChainLookupStrategy(
+            @Nonnull final Function<SignatureSigningParameters, List<Base64>> strategy) {
+        checkSetterPreconditions();
+        certificateChainLookupStrategy = Constraint.isNotNull(strategy,
+                "CertificateChainLookupStrategy lookup strategy cannot be null");
+    }
+
+    /**
+     * Get the signing parameters to apply.
+     *
+     * @return the signing parameters
+     */
+    @Nullable
+    public SignatureSigningParameters getSignatureSigningParameters() {
+        return signatureSigningParameters;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+
+        if (!super.doPreExecute(profileRequestContext)) {
+            return false;
+        }
+
+        ctx = statusListTokenContextLookupStrategy.apply(profileRequestContext);
+        if (ctx == null || ctx.getClaims() == null) {
+            log.error("{} No status list token to sign", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+            return false;
+        }
+
+        final SecurityParametersContext secParamCtx = securityParametersLookupStrategy.apply(profileRequestContext);
+        if (secParamCtx == null) {
+            log.debug("{} no security parameters context is available", getLogPrefix());
+            return false;
+        }
+
+        signatureSigningParameters = secParamCtx.getSignatureSigningParameters();
+        if (signatureSigningParameters == null) {
+            log.debug("{} no signature signing parameters available", getLogPrefix());
+            return false;
+        }
+        signingCredential = signatureSigningParameters.getSigningCredential();
+        if (signingCredential == null) {
+            log.debug("{} no signature signing credential available", getLogPrefix());
+            return false;
+        }
+        if (signingCredential.getPrivateKey() instanceof final ECPrivateKey ecKey) {
+            privateKey = ecKey;
+            return true;
+        }
+        log.error("{} No EC private key as signing parameter", getLogPrefix());
+        ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_SEC_CFG);
+        return false;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+        try {
+            // For now we support only EC family.
+            final SignedJWT signedJWT = new SignedJWT(
+                    new JWSHeader.Builder(new JWSAlgorithm(signatureSigningParameters.getSignatureAlgorithm()))
+                            .x509CertChain(certificateChainLookupStrategy.apply(signatureSigningParameters))
+                            .type(new JOSEObjectType(TOKEN_TYPE))
+                            .keyID(CredentialConversionUtil.resolveKid(signingCredential)).build(),
+                    ctx.getClaims());
+
+            signedJWT.sign(new ECDSASigner(privateKey));
+            ctx.setSignedToken(signedJWT.serialize());
+
+            log.debug("{} Signed status list token for {}", getLogPrefix(), ctx.getClaims().getSubject());
+        } catch (final JOSEException e) {
+            log.error("{} Signing status list token failed", getLogPrefix(), e);
+            ActionSupport.buildEvent(profileRequestContext, EventIds.IO_ERROR);
+        }
+    }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/FeistelIndexPermutation.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/FeistelIndexPermutation.java
new file mode 100644
index 0000000..2d8e418
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/FeistelIndexPermutation.java
@@ -0,0 +1,122 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.oauth.statuslist.storage;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+
+import javax.annotation.Nonnull;
+
+import net.shibboleth.shared.logic.Constraint;
+
+/**
+ * Keyed permutation of the slot space as described in
+ * https://en.wikipedia.org/wiki/Feistel_cipher and sources linked in that.
+ * 
+ * Idea is to walk through range of values in unpredictable order.
+ *
+ * TODO: further verification this works as intended.
+ */
+public class FeistelIndexPermutation {
+
+    /** Feistel round count. Ten as in FF1 of NIST SP 800-38G. */
+    private static final int ROUNDS = 10;
+
+    /** Digest algorithm backing the round function. */
+    @Nonnull
+    private static final String DIGEST = "SHA-256";
+
+    /** Width in bits of each half of the permuted value. */
+    private final int halfBits;
+
+    /** Mask retaining one half of the permuted value. */
+    private final int halfMask;
+
+    /** Key material for the round function. */
+    @Nonnull
+    private final byte[] key;
+
+    /**
+     * Constructor.
+     *
+     * @param bits        width of the permuted space, even and between 8 and 32
+     * @param keyMaterial key for the round function
+     */
+    public FeistelIndexPermutation(final int bits, @Nonnull final byte[] keyMaterial) {
+        Constraint.isTrue(bits >= 8 && bits <= 32, "Permutation width must be between 8 and 32 bits");
+        Constraint.isTrue(bits % 2 == 0, "Permutation width must be even");
+        Constraint.isNotNull(keyMaterial, "Permutation key cannot be null");
+        Constraint.isTrue(keyMaterial.length > 0, "Permutation key cannot be empty");
+
+        halfBits = bits / 2;
+        halfMask = (1 << halfBits) - 1;
+        key = keyMaterial.clone();
+    }
+
+    /**
+     * Permute a counter value into an index.
+     *
+     * @param counter value to permute, less than {@code 2^bits}
+     * @param tweak   value separating one bucket's permutation from another's
+     * @return permuted index
+     */
+    public long permute(final long counter, @Nonnull final String tweak) {
+        Constraint.isNotNull(tweak, "Tweak cannot be null");
+
+        int left = (int) ((counter >>> halfBits) & halfMask);
+        int right = (int) (counter & halfMask);
+
+        for (int round = 0; round < ROUNDS; round++) {
+            final int next = left ^ roundFunction(tweak, round, right);
+            left = right;
+            right = next;
+        }
+
+        return ((long) left << halfBits) | right;
+    }
+
+    /**
+     * Keyed round function, reduced to one half of the permuted space.
+     *
+     * @param tweak value separating one bucket's permutation from another's
+     * @param round round ordinal
+     * @param value half to mix
+     * @return pseudorandom value of {@code halfBits} width
+     */
+    private int roundFunction(@Nonnull final String tweak, final int round, final int value) {
+        final MessageDigest digest;
+        try {
+            digest = MessageDigest.getInstance(DIGEST);
+        } catch (final NoSuchAlgorithmException e) {
+            // SHA-256 is required of every Java runtime.
+            throw new IllegalStateException(DIGEST + " unavailable", e);
+        }
+
+        digest.update(key);
+        digest.update(tweak.getBytes(StandardCharsets.UTF_8));
+        digest.update((byte) round);
+        digest.update(new byte[] { (byte) (value >>> 24), (byte) (value >>> 16), (byte) (value >>> 8), (byte) value });
+
+        final byte[] hash = digest.digest();
+        final int mixed = ((hash[0] & 0xff) << 24) | ((hash[1] & 0xff) << 16) | ((hash[2] & 0xff) << 8)
+                | (hash[3] & 0xff);
+
+        return mixed & halfMask;
+    }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListAssignment.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListAssignment.java
new file mode 100644
index 0000000..63a19ca
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListAssignment.java
@@ -0,0 +1,183 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.oauth.statuslist.storage;
+
+import java.util.List;
+import java.util.Map;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+/**
+ * Record of status list slots assigned to something, to be collected by a party
+ * outside the IdP.
+ *
+ * A status list holds only bits, so without this record there is no way to find
+ * the slots to revoke. Nothing in the IdP reads the record back.
+ *
+ * {@link #getDetails()} describes what took the slots. It is given by the
+ * issuing protocol and not interpreted here.
+ */
+ at JsonIgnoreProperties(ignoreUnknown = true)
+public class StatusListAssignment {
+
+    /** Position of the record in the sequence of records, its key. */
+    @JsonProperty("seq")
+    private long seq;
+
+    /** Identifier of the status list the slots belong to. */
+    @JsonProperty("id")
+    private String id;
+
+    /** Slots of the status list assigned. */
+    @JsonProperty("idx")
+    private List<Long> indices;
+
+    /** Expiration of what took the slots, in seconds since the epoch. */
+    @JsonProperty("expires")
+    private long expires;
+
+    /** What took the slots, as the issuing protocol describes it. */
+    @JsonProperty("details")
+    private Map<String, Object> details;
+
+    /** Constructor. */
+    public StatusListAssignment() {
+    }
+
+    /**
+     * Get position of the record in the sequence of records.
+     *
+     * @return Position of the record in the sequence of records
+     */
+    public long getSeq() {
+        return seq;
+    }
+
+    /**
+     * Set position of the record in the sequence of records.
+     *
+     * @param position Position of the record in the sequence of records
+     */
+    public void setSeq(final long position) {
+        seq = position;
+    }
+
+    /**
+     * Get identifier of the status list the slots belong to.
+     *
+     * @return Identifier of the status list the slots belong to
+     */
+    @Nullable
+    public String getId() {
+        return id;
+    }
+
+    /**
+     * Set identifier of the status list the slots belong to.
+     *
+     * @param listId Identifier of the status list the slots belong to
+     */
+    public void setId(@Nullable final String listId) {
+        id = listId;
+    }
+
+    /**
+     * Get slots of the status list assigned.
+     *
+     * @return Slots of the status list assigned
+     */
+    @Nullable
+    public List<Long> getIndices() {
+        return indices;
+    }
+
+    /**
+     * Set slots of the status list assigned.
+     *
+     * @param slots Slots of the status list assigned
+     */
+    public void setIndices(@Nullable final List<Long> slots) {
+        indices = slots;
+    }
+
+    /**
+     * Get expiration of what took the slots, in seconds since the epoch.
+     *
+     * @return Expiration of what took the slots
+     */
+    public long getExpires() {
+        return expires;
+    }
+
+    /**
+     * Set expiration of what took the slots, in seconds since the epoch.
+     *
+     * @param expiration Expiration of what took the slots
+     */
+    public void setExpires(final long expiration) {
+        expires = expiration;
+    }
+
+    /**
+     * Get what took the slots, as the issuing protocol describes it.
+     *
+     * @return What took the slots
+     */
+    @Nullable
+    public Map<String, Object> getDetails() {
+        return details;
+    }
+
+    /**
+     * Set what took the slots, as the issuing protocol describes it.
+     *
+     * @param protocolDetails What took the slots
+     */
+    public void setDetails(@Nullable final Map<String, Object> protocolDetails) {
+        details = protocolDetails;
+    }
+
+    /**
+     * Serialize the record to json.
+     *
+     * @return the record as json
+     * @throws JsonProcessingException if serialization fails
+     */
+    @Nonnull
+    public String serialize() throws JsonProcessingException {
+        return new ObjectMapper().writeValueAsString(this);
+    }
+
+    /**
+     * Parse a record from json.
+     *
+     * @param record the record as json
+     * @return parsed record
+     * @throws JsonProcessingException if parsing fails
+     */
+    @Nonnull
+    public static StatusListAssignment parse(@Nonnull final String record) throws JsonProcessingException {
+        return new ObjectMapper().readValue(record, StatusListAssignment.class);
+    }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListAssignmentStore.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListAssignmentStore.java
new file mode 100644
index 0000000..35d9b1a
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListAssignmentStore.java
@@ -0,0 +1,261 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.oauth.statuslist.storage;
+
+import java.io.IOException;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+
+import javax.annotation.Nonnull;
+
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+import net.shibboleth.shared.annotation.constraint.ThreadSafeAfterInit;
+import net.shibboleth.shared.component.AbstractIdentifiableInitializableComponent;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.Constraint;
+
+import org.opensaml.storage.StorageCapabilities;
+import org.opensaml.storage.StorageRecord;
+import org.opensaml.storage.StorageService;
+import org.opensaml.storage.VersionMismatchException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+
+/**
+ * Store handing {@link StatusListAssignment} records to a collector outside the
+ * IdP, backed by Shibboleth {@link StorageService}.
+ *
+ * Records are numbered each taking the next position of a sequence stored with
+ * them. A collector reads page from a position it remembers and continues from
+ * the end of the page.
+ */
+ at ThreadSafeAfterInit
+public class StatusListAssignmentStore extends AbstractIdentifiableInitializableComponent {
+
+    /** Logger. */
+    @Nonnull
+    private final Logger log = LoggerFactory.getLogger(StatusListAssignmentStore.class);
+
+    /** Storage context for the records. */
+    @Nonnull
+    @NotEmpty
+    private static final String CONTEXT = StatusListAssignmentStore.class.getName();
+
+    /**
+     * Storage key of the sequence, which no record can take, records being numbers.
+     */
+    @Nonnull
+    @NotEmpty
+    private static final String KEY_SEQUENCE = "sequence";
+
+    /** Bounded retry cap for optimistic-update contention. */
+    private static final int MAX_RETRIES = 32;
+
+    /** Positions probed per record asked for, bounding the work of one read. */
+    private static final int PROBE_FACTOR = 10;
+
+    /** How long a record waits to be collected. */
+    @Nonnull
+    private Duration retention = Duration.ofDays(7);
+
+    /** Backing storage for the records. */
+    private StorageService storage;
+
+    /**
+     * Get the backing store.
+     *
+     * @return the backing store
+     */
+    @NonnullAfterInit
+    public StorageService getStorage() {
+        return storage;
+    }
+
+    /**
+     * Set the backing store.
+     *
+     * @param storageService backing store to use
+     */
+    public void setStorage(@Nonnull final StorageService storageService) {
+        checkSetterPreconditions();
+
+        storage = Constraint.isNotNull(storageService, "StorageService cannot be null");
+        final StorageCapabilities caps = storage.getCapabilities();
+        Constraint.isTrue(caps.isServerSide(), "StorageService cannot be client-side");
+        Constraint.isTrue(CONTEXT.length() <= caps.getContextSize(),
+                "Context " + CONTEXT.length() + " too long for StorageService " + caps.getContextSize());
+    }
+
+    /**
+     * Get how long a record waits to be collected.
+     *
+     * @return retention of a record
+     */
+    @Nonnull
+    public Duration getRetention() {
+        return retention;
+    }
+
+    /**
+     * Set how long a record waits to be collected.
+     *
+     * Long enough that a collector being down does not lose the possibility to
+     * revoke what was issued in the meantime.
+     *
+     * @param recordRetention positive retention
+     */
+    public void setRetention(@Nonnull final Duration recordRetention) {
+        checkSetterPreconditions();
+        Constraint.isNotNull(recordRetention, "Retention cannot be null");
+        Constraint.isFalse(recordRetention.isNegative() || recordRetention.isZero(), "Retention must be positive");
+        retention = recordRetention;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
+
+        if (storage == null) {
+            throw new ComponentInitializationException("StorageService cannot be null");
+        }
+        log.info("Records of status list assignments are kept {} for collection", retention);
+    }
+
+    /**
+     * Store a record of an assignment.
+     *
+     * The record takes the next position of the sequence.
+     *
+     * @param record record to store
+     * @throws IOException if the record cannot be serialized or stored, or if
+     *                     contention could not be resolved within
+     *                     {@value #MAX_RETRIES} attempts
+     */
+    public void store(@Nonnull final StatusListAssignment record) throws IOException {
+        checkComponentActive();
+        Constraint.isNotNull(record, "Record cannot be null");
+
+        final long seq = nextSequence();
+        record.setSeq(seq);
+
+        final String value;
+        try {
+            value = record.serialize();
+        } catch (final JsonProcessingException e) {
+            throw new IOException("Unable to serialize record of a status list assignment", e);
+        }
+
+        if (!storage.create(CONTEXT, Long.toString(seq), value, Instant.now().plus(retention).toEpochMilli())) {
+            throw new IOException("Record of a status list assignment was not stored at " + seq);
+        }
+
+        log.debug("Stored record {} of {} slot(s) of status list {}", seq,
+                record.getIndices() == null ? 0 : record.getIndices().size(), record.getId());
+    }
+
+    /**
+     * Read a page of records.
+     *
+     * Positions are read one by one and an empty position is skipped, an expired
+     * record leaves a gap. The number of positions read is bounded, so a collector
+     * that is far behind gets one page at a time.
+     *
+     * @param from  position to read from
+     * @param limit most records to return
+     * @return the records read, and where to continue
+     * @throws IOException if the storage backend errors
+     */
+    @Nonnull
+    public StatusListAssignments read(final long from, final int limit) throws IOException {
+        checkComponentActive();
+        Constraint.isTrue(from >= 0, "Position to read from cannot be negative");
+        Constraint.isGreaterThan(0, limit, "Limit must be positive");
+
+        final long highWater = currentSequence();
+        final long ceiling = Math.min(highWater, from + (long) limit * PROBE_FACTOR);
+
+        final List<StatusListAssignment> records = new ArrayList<>();
+        long seq = from;
+        while (seq < ceiling && records.size() < limit) {
+            final StorageRecord<String> stored = storage.read(CONTEXT, Long.toString(seq));
+            if (stored != null) {
+                try {
+                    records.add(StatusListAssignment.parse(stored.getValue()));
+                } catch (final JsonProcessingException e) {
+                    throw new IOException("Unable to parse record of a status list assignment at " + seq, e);
+                }
+            }
+            seq++;
+        }
+
+        log.debug("Read {} record(s) from {}, continuing at {} of {}", records.size(), from, seq, highWater);
+
+        return new StatusListAssignments(records, seq, highWater);
+    }
+
+    /**
+     * Get the position beyond the last record written.
+     *
+     * @return position beyond the last record written
+     * @throws IOException if the storage backend errors
+     */
+    private long currentSequence() throws IOException {
+        final StorageRecord<String> record = storage.read(CONTEXT, KEY_SEQUENCE);
+
+        return record == null ? 0L : Long.parseLong(record.getValue());
+    }
+
+    /**
+     * Take the next position in the sequence.
+     *
+     * @return position taken
+     * @throws IOException if the storage backend errors, or if contention could not
+     *                     be resolved within {@value #MAX_RETRIES} attempts
+     */
+    private long nextSequence() throws IOException {
+        for (int attempt = 0; attempt < MAX_RETRIES; attempt++) {
+            final StorageRecord<String> current = storage.read(CONTEXT, KEY_SEQUENCE);
+            final long seq = current == null ? 0L : Long.parseLong(current.getValue());
+            final String updated = Long.toString(seq + 1);
+
+            if (current == null) {
+                // The sequence outlives every record it numbers, so it is kept without
+                // expiration.
+                if (storage.create(CONTEXT, KEY_SEQUENCE, updated, null)) {
+                    return seq;
+                }
+            } else {
+                try {
+                    storage.updateWithVersion(current.getVersion(), CONTEXT, KEY_SEQUENCE, updated, null);
+                    return seq;
+                } catch (final VersionMismatchException e) {
+                    log.trace("Version mismatch on the record sequence, attempt {}/{}", attempt + 1, MAX_RETRIES);
+                }
+            }
+        }
+
+        throw new IOException(
+                "Failed to take a record position after " + MAX_RETRIES + " attempts (storage contention).");
+    }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListAssignments.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListAssignments.java
new file mode 100644
index 0000000..271e136
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListAssignments.java
@@ -0,0 +1,104 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.oauth.statuslist.storage;
+
+import java.util.List;
+
+import javax.annotation.Nonnull;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+/**
+ * A page of {@link StatusListAssignment} records for a collector.
+ *
+ * The collector stores {@link #getNext()} and gives it back to continue. No state of
+ * a reader is kept here. {@link #getHighWater()} tells how far the records go, so a
+ * collector knows when it is up to date.
+ *
+ * Gaps in the sequence are normal.
+ */
+public class StatusListAssignments {
+
+    /** The records read. */
+    @Nonnull
+    @JsonProperty("records")
+    private final List<StatusListAssignment> records;
+
+    /** Position to continue from. */
+    @JsonProperty("next")
+    private final long next;
+
+    /** Position beyond the last record written. */
+    @JsonProperty("high_water")
+    private final long highWater;
+
+    /**
+     * Constructor.
+     *
+     * @param read    the records read
+     * @param nextSeq position to continue from
+     * @param written position beyond the last record written
+     */
+    public StatusListAssignments(@Nonnull final List<StatusListAssignment> read, final long nextSeq,
+            final long written) {
+        records = List.copyOf(read);
+        next = nextSeq;
+        highWater = written;
+    }
+
+    /**
+     * Get the records read.
+     *
+     * @return The records read
+     */
+    @Nonnull
+    public List<StatusListAssignment> getRecords() {
+        return records;
+    }
+
+    /**
+     * Get position to continue from.
+     *
+     * @return Position to continue from
+     */
+    public long getNext() {
+        return next;
+    }
+
+    /**
+     * Get position beyond the last record written.
+     *
+     * @return Position beyond the last record written
+     */
+    public long getHighWater() {
+        return highWater;
+    }
+
+    /**
+     * Serialize the page to json.
+     *
+     * @return the page as json
+     * @throws JsonProcessingException if serialization fails
+     */
+    @Nonnull
+    public String serialize() throws JsonProcessingException {
+        return new ObjectMapper().writeValueAsString(this);
+    }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListIndexAllocator.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListIndexAllocator.java
index 79a0c14..808c5f8 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListIndexAllocator.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListIndexAllocator.java
@@ -17,6 +17,10 @@
 package org.geant.shibboleth.plugin.oauth.statuslist.storage;
 
 import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
 
 import javax.annotation.Nonnull;
 
@@ -27,7 +31,8 @@ import net.shibboleth.shared.component.AbstractIdentifiableInitializableComponen
 import net.shibboleth.shared.component.ComponentInitializationException;
 import net.shibboleth.shared.logic.Constraint;
 
-import org.geant.shibboleth.plugin.oauth.statuslist.StatusListIndex;
+import org.geant.shibboleth.plugin.oauth.statuslist.StatusListBucket;
+import org.geant.shibboleth.plugin.oauth.statuslist.StatusListParameters;
 import org.opensaml.storage.StorageCapabilities;
 import org.opensaml.storage.StorageRecord;
 import org.opensaml.storage.StorageService;
@@ -36,11 +41,14 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 /**
- * Allocator producing increasing {@link StatusListIndex} values, backed by
- * Shibboleth {@link StorageService}.
+ * Allocator of status list slots, backed by Shibboleth {@link StorageService}.
  *
- * Records use no expiration. Memory-backed StorageService is not
- * suitable for real deployment.
+ * State is one counter per {@link StatusListBucket} stored under the bucket
+ * identifier. Counter values are mapped to indices with a keyed permutation
+ * (order cannot be read from an issued credential( and slots allocated together
+ * are not consecutive. A bucket has {@value #CAPACITY} slots.
+ *
+ * A record expires after the end of the bucket.
  */
 @ThreadSafeAfterInit
 public class StatusListIndexAllocator extends AbstractIdentifiableInitializableComponent {
@@ -54,21 +62,39 @@ public class StatusListIndexAllocator extends AbstractIdentifiableInitializableC
     @NotEmpty
     private static final String CONTEXT = StatusListIndexAllocator.class.getName();
 
-    /** Storage key for the packed counter. */
+    /** Bounded retry cap for optimistic-update contention. */
+    private static final int MAX_RETRIES = 32;
+
+    /**
+     * Permutation key shipped in the plugin's properties, to be replaced on
+     * install.
+     */
     @Nonnull
     @NotEmpty
-    private static final String KEY_COUNTER = "counter";
+    private static final String PLACEHOLDER_KEY = "change_this_to_secret";
 
-    /** Delimeter between {@code uriIndex} and {@code index} in the packed value. */
+    /** Width in bits of the slot space of a single bucket. */
+    private static final int INDEX_BITS = StatusListParameters.INDEX_BITS;
+
+    /** Number of slots in a single bucket. */
+    private static final long CAPACITY = StatusListParameters.CAPACITY;
+
+    /**
+     * Time the counter of a bucket is kept after the end of the bucket. Losing it
+     * earlier restarts it from zero and the same slots are issued again.
+     */
     @Nonnull
-    @NotEmpty
-    private static final String DEL = ":";
+    private static final Duration RETENTION = StatusListParameters.RETENTION;
 
-    /** Bounded retry cap for optimistic-update contention. */
-    private static final int MAX_RETRIES  = 32;
+    /** Defensive ceiling on the number of slots one call may draw. */
+    private int maxSlotsPerAllocation = 1024;
 
-    /** Max slots per list before rolling to the next {@code uriIndex}. */
-    private int maxIndex = 500_000;
+    /** Key stabilising the counter-to-index permutation. */
+    private String permutationKey;
+
+    /** Permutation turning counter values into indices. */
+    @NonnullAfterInit
+    private FeistelIndexPermutation permutation;
 
     /** Backing storage for the allocator state. */
     private StorageService storage;
@@ -99,102 +125,145 @@ public class StatusListIndexAllocator extends AbstractIdentifiableInitializableC
     }
 
     /**
-     * Get the maximum number of slots per list before rollover.
+     * Get the number of slots in a single bucket.
+     *
+     * @return bucket capacity
+     */
+    public long getCapacity() {
+        return CAPACITY;
+    }
+
+    /**
+     * Get the ceiling on the number of slots one call may draw.
      *
-     * @return max slots per list
+     * @return maximum slots per allocation
      */
-    public int getMaxIndex() {
-        return maxIndex;
+    public int getMaxSlotsPerAllocation() {
+        return maxSlotsPerAllocation;
     }
 
     /**
-     * Set the maximum number of slots per list before rollover.
+     * Set the ceiling on the number of slots one call may draw.
+     *
+     * This is a defensive bound against a miscounted request, not a policy limit on
+     * batch size.
      *
      * @param max positive maximum
      */
-    public void setMaxIndex(final int max) {
+    public void setMaxSlotsPerAllocation(final int max) {
         checkSetterPreconditions();
-        Constraint.isGreaterThan(999, max, "maxIndex must be at least 1000");
-        maxIndex = max;
+        Constraint.isGreaterThan(0, max, "maxSlotsPerAllocation must be positive");
+        maxSlotsPerAllocation = max;
+    }
+
+    /**
+     * Set the key stabilising the counter-to-index permutation.
+     *
+     * A secret. Must not change after credentials have been issued, two keys can
+     * put two counters of the same bucket at the same index.
+     *
+     * @param key key material
+     */
+    public void setPermutationKey(@Nonnull @NotEmpty final String key) {
+        checkSetterPreconditions();
+        permutationKey = Constraint.isNotEmpty(key, "Permutation key cannot be empty");
     }
 
     /** {@inheritDoc} */
     @Override
-    public void doInitialize() throws ComponentInitializationException {
+    protected void doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
+
         if (storage == null) {
             throw new ComponentInitializationException("StorageService cannot be null");
         }
+        if (permutationKey == null || permutationKey.isEmpty()) {
+            throw new ComponentInitializationException("Permutation key cannot be null or empty; set "
+                    + "oauth.statuslist.indexPermutationKey to a stable secret value");
+        }
+        if (PLACEHOLDER_KEY.equals(permutationKey)) {
+            log.warn("Status list indices are permuted with the key shipped in the plugin's properties. "
+                    + "Anyone can recover the issuance order of issued credentials until "
+                    + "oauth.statuslist.indexPermutationKey is changed, and changing it later reissues slots.");
+        }
+        permutation = new FeistelIndexPermutation(INDEX_BITS, permutationKey.getBytes(StandardCharsets.UTF_8));
     }
 
     /**
-     * Allocate the next slot.
+     * Allocate slots from a bucket.
      *
-     * Reads the current counter, computes the next {@code (uriIndex, index)}, and
-     * commits it with an optimistic update. Retries on contention up to
-     * {@value #MAX_RETRIES} times.
+     * Advances the bucket's counter by {@code count} with an optimistic update,
+     * retrying on contention up to {@value #MAX_RETRIES} times, then maps the
+     * consumed counter values to indices.
      *
-     * @return newly allocated slot
-     * @throws IOException if the storage backend errors, or if contention could not
-     *                     be resolved within {@value #MAX_RETRIES} attempts
+     * @param bucket bucket to draw from
+     * @param count  number of slots needed
+     * @return newly allocated indices, in the order the credentials were requested
+     * @throws IOException if the bucket is exhausted, if the storage backend
+     *                     errors, or if contention could not be resolved within
+     *                     {@value #MAX_RETRIES} attempts
      */
     @Nonnull
-    public StatusListIndex allocate() throws IOException {
-        for (int attempt = 0; attempt < MAX_RETRIES; attempt++) {
-            final StorageRecord<String> current = storage.read(CONTEXT, KEY_COUNTER);
+    public List<Long> allocate(@Nonnull final StatusListBucket bucket, final int count) throws IOException {
+        checkComponentActive();
+        Constraint.isNotNull(bucket, "Bucket cannot be null");
+        Constraint.isGreaterThan(0, count, "Count must be positive");
+        Constraint.isTrue(count <= maxSlotsPerAllocation,
+                "Count " + count + " exceeds maximum " + maxSlotsPerAllocation + " slots per allocation");
 
-            final long uriIndex;
-            final int index;
-            if (current == null) {
-                uriIndex = 0L;
-                index = -1;
-            } else {
-                final String[] parts = current.getValue().split(DEL, 2);
-                uriIndex = Long.parseLong(parts[0]);
-                index = Integer.parseInt(parts[1]);
-            }
+        final String key = bucket.id();
+        final Long expiration = bucket.end().plus(RETENTION).toEpochMilli();
+
+        for (int attempt = 0; attempt < MAX_RETRIES; attempt++) {
+            final StorageRecord<String> current = storage.read(CONTEXT, key);
+            final long allocated = current == null ? 0L : Long.parseLong(current.getValue());
 
-            int nextIndex = index + 1;
-            long nextUriIndex = uriIndex;
-            if (nextIndex >= maxIndex) {
-                nextIndex = 0;
-                nextUriIndex = uriIndex + 1L;
+            if (allocated + count > CAPACITY) {
+                throw new IOException("Status list bucket " + key + " exhausted: " + allocated + " of " + CAPACITY
+                        + " slots allocated, " + count + " more requested.");
             }
 
-            final String nextValue = nextUriIndex + DEL + nextIndex;
+            final String nextValue = Long.toString(allocated + count);
 
             final boolean committed;
             if (current == null) {
-                committed = storage.create(CONTEXT, KEY_COUNTER, nextValue, null);
+                committed = storage.create(CONTEXT, key, nextValue, expiration);
             } else {
-                committed = tryUpdate(current.getVersion(), nextValue);
+                committed = tryUpdate(current.getVersion(), key, nextValue, expiration);
             }
 
             if (committed) {
-                log.debug("Allocated status list slot uriIndex={} index={}", nextUriIndex, nextIndex);
-                return new StatusListIndex(nextIndex, nextUriIndex);
+                final List<Long> indices = new ArrayList<>(count);
+                for (long counter = allocated; counter < allocated + count; counter++) {
+                    indices.add(permutation.permute(counter, key));
+                }
+                log.debug("Allocated {} status list slot(s) from bucket {}: {}", count, key, indices);
+                return indices;
             }
 
-            log.trace("Version mismatch on status list counter, attempt {}/{}", attempt + 1, MAX_RETRIES);
+            log.trace("Version mismatch on status list counter for bucket {}, attempt {}/{}", key, attempt + 1,
+                    MAX_RETRIES);
         }
 
-        throw new IOException(
-                "Failed to allocate status list slot after " + MAX_RETRIES + " attempts (storage contention).");
+        throw new IOException("Failed to allocate status list slots from bucket " + key + " after " + MAX_RETRIES
+                + " attempts (storage contention).");
     }
 
     /**
-     * Update of the counter, returning {@code false} on version
-     * mismatch.
+     * Update of a bucket's counter, returning {@code false} on version mismatch.
      *
-     * @param version expected version
-     * @param value   new packed value
+     * @param version    expected version
+     * @param key        bucket identifier
+     * @param value      new counter value
+     * @param expiration when the bucket's state may be discarded
      * @return {@code true} if the update committed, {@code false} on version
      *         mismatch
      * @throws IOException on any other storage error
      */
-    private boolean tryUpdate(final long version, @Nonnull final String value) throws IOException {
+    private boolean tryUpdate(final long version, @Nonnull final String key, @Nonnull final String value,
+            @Nonnull final Long expiration) throws IOException {
         try {
-            // null expiration = never expires; see class javadoc.
-            storage.updateWithVersion(version, CONTEXT, KEY_COUNTER, value, null);
+            storage.updateWithVersion(version, CONTEXT, key, value, expiration);
             return true;
         } catch (final VersionMismatchException e) {
             return false;
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListStore.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListStore.java
new file mode 100644
index 0000000..9280c07
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListStore.java
@@ -0,0 +1,242 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.oauth.statuslist.storage;
+
+import java.io.IOException;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+import net.shibboleth.shared.annotation.constraint.ThreadSafeAfterInit;
+import net.shibboleth.shared.component.AbstractIdentifiableInitializableComponent;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.Constraint;
+
+import org.geant.shibboleth.plugin.oauth.statuslist.StatusListBucket;
+import org.geant.shibboleth.plugin.oauth.statuslist.StatusListCodec;
+import org.geant.shibboleth.plugin.oauth.statuslist.StatusListParameters;
+import org.opensaml.storage.StorageCapabilities;
+import org.opensaml.storage.StorageRecord;
+import org.opensaml.storage.StorageService;
+import org.opensaml.storage.VersionMismatchException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Store of the status lists this issuer publishes, backed by Shibboleth
+ * {@link StorageService}.
+ *
+ * A list is stored per {@link StatusListBucket} in
+ * {@value org.geant.shibboleth.plugin.oauth.statuslist.StatusListParameters#CHUNKS}
+ * pieces of fixed size, keyed by the bucket identifier and the number of the
+ * piece. Records expire when no credential of the bucket can be valid anymore.
+ */
+ at ThreadSafeAfterInit
+public class StatusListStore extends AbstractIdentifiableInitializableComponent {
+
+    /** Logger. */
+    @Nonnull
+    private final Logger log = LoggerFactory.getLogger(StatusListStore.class);
+
+    /** Storage context for the lists. */
+    @Nonnull
+    @NotEmpty
+    private static final String CONTEXT = StatusListStore.class.getName();
+
+    /** Bounded retry cap for optimistic-update contention. */
+    private static final int MAX_RETRIES = 32;
+
+    /** Backing storage for the lists. */
+    private StorageService storage;
+
+    /**
+     * Get the backing store.
+     *
+     * @return the backing store
+     */
+    @NonnullAfterInit
+    public StorageService getStorage() {
+        return storage;
+    }
+
+    /**
+     * Set the backing store.
+     *
+     * @param storageService backing store to use
+     */
+    public void setStorage(@Nonnull final StorageService storageService) {
+        checkSetterPreconditions();
+
+        storage = Constraint.isNotNull(storageService, "StorageService cannot be null");
+        final StorageCapabilities caps = storage.getCapabilities();
+        Constraint.isTrue(caps.isServerSide(), "StorageService cannot be client-side");
+        Constraint.isTrue(CONTEXT.length() <= caps.getContextSize(),
+                "Context " + CONTEXT.length() + " too long for StorageService " + caps.getContextSize());
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
+
+        if (storage == null) {
+            throw new ComponentInitializationException("StorageService cannot be null");
+        }
+
+        // Pieces are of fixed size, so a store wide enough for one is wide enough for
+        // every list this
+        // issuer will ever publish, whatever gets revoked.
+        final long required = StatusListCodec.encodeChunk(StatusListCodec.emptyChunk()).length();
+        final long valueSize = storage.getCapabilities().getValueSize();
+        if (valueSize < required) {
+            throw new ComponentInitializationException(
+                    "StorageService holds values of " + valueSize + " characters, status list pieces need " + required);
+        }
+        log.debug("Status lists of {} slots are stored in {} pieces of {} characters", StatusListParameters.CAPACITY,
+                StatusListParameters.CHUNKS, required);
+    }
+
+    /**
+     * Read the list of a bucket in the form a published token carries it.
+     *
+     * Combines the pieces of the list. A piece that was never written is skipped.
+     *
+     * @param bucket bucket to read
+     * @return the encoded list, or null if nothing is revoked in it
+     * @throws IOException if a stored piece cannot be read or the storage backend
+     *                     fails
+     */
+    @Nullable
+    public String read(@Nonnull final StatusListBucket bucket) throws IOException {
+        checkComponentActive();
+        Constraint.isNotNull(bucket, "Bucket cannot be null");
+
+        final byte[] list = StatusListCodec.empty();
+        int pieces = 0;
+
+        for (int chunk = 0; chunk < StatusListParameters.CHUNKS; chunk++) {
+            final StorageRecord<String> record = storage.read(CONTEXT, key(bucket, chunk));
+            if (record == null) {
+                continue;
+            }
+            System.arraycopy(StatusListCodec.decodeChunk(record.getValue()), 0, list,
+                    chunk * StatusListParameters.CHUNK_BYTE_LENGTH, StatusListParameters.CHUNK_BYTE_LENGTH);
+            pieces++;
+        }
+
+        if (pieces == 0) {
+            return null;
+        }
+
+        log.debug("Assembled status list {} from {} stored piece(s)", bucket.id(), pieces);
+
+        return StatusListCodec.encode(list);
+    }
+
+    /**
+     * Get the storage key of one piece of a bucket's list.
+     *
+     * @param bucket bucket owning the list
+     * @param chunk  piece of the list
+     * @return storage key
+     */
+    @Nonnull
+    private static String key(@Nonnull final StatusListBucket bucket, final int chunk) {
+        return bucket.id() + "#" + chunk;
+    }
+
+    /**
+     * Revoke a slot of a list.
+     *
+     * Reads the piece of the slot, sets the slot and commits with an optimistic
+     * update. Retries on contention {@value #MAX_RETRIES} times. The piece is
+     * created if it does not exist. Revoking an already revoked slot does nothing.
+     *
+     * @param bucket bucket of the list
+     * @param index  slot to revoke
+     * @throws IOException if the stored piece cannot be read, the storage backend
+     *                     fails or contention was not resolved in
+     *                     {@value #MAX_RETRIES} attempts
+     */
+    public void revoke(@Nonnull final StatusListBucket bucket, final long index) throws IOException {
+        checkComponentActive();
+        Constraint.isNotNull(bucket, "Bucket cannot be null");
+        Constraint.isTrue(index >= 0 && index < StatusListParameters.CAPACITY,
+                "Slot " + index + " outside a list of " + StatusListParameters.CAPACITY);
+
+        final int chunk = StatusListParameters.chunkOf(index);
+        final long offset = StatusListParameters.offsetInChunk(index);
+        final String key = key(bucket, chunk);
+        final Long expiration = bucket.end().plus(StatusListParameters.RETENTION).toEpochMilli();
+
+        for (int attempt = 0; attempt < MAX_RETRIES; attempt++) {
+            final StorageRecord<String> current = storage.read(CONTEXT, key);
+
+            final byte[] piece = current == null ? StatusListCodec.emptyChunk()
+                    : StatusListCodec.decodeChunk(current.getValue());
+
+            if (!StatusListCodec.isValid(piece, offset)) {
+                log.debug("Slot {} of status list {} is already revoked", index, bucket.id());
+                return;
+            }
+
+            StatusListCodec.setStatus(piece, offset, false);
+            final String updated = StatusListCodec.encodeChunk(piece);
+
+            final boolean committed;
+            if (current == null) {
+                committed = storage.create(CONTEXT, key, updated, expiration);
+            } else {
+                committed = tryUpdate(current.getVersion(), key, updated, expiration);
+            }
+
+            if (committed) {
+                log.info("Revoked slot {} of status list {}", index, bucket.id());
+                return;
+            }
+
+            log.trace("Version mismatch on status list piece {}, attempt {}/{}", key, attempt + 1, MAX_RETRIES);
+        }
+
+        throw new IOException("Failed to revoke slot " + index + " of status list " + bucket.id() + " after "
+                + MAX_RETRIES + " attempts (storage contention).");
+    }
+
+    /**
+     * Update of a list, returning {@code false} on version mismatch.
+     *
+     * @param version    expected version
+     * @param key        bucket identifier
+     * @param value      new encoded list
+     * @param expiration when the list may be discarded
+     * @return {@code true} if the update committed, {@code false} on version
+     *         mismatch
+     * @throws IOException on any other storage error
+     */
+    private boolean tryUpdate(final long version, @Nonnull final String key, @Nonnull final String value,
+            @Nonnull final Long expiration) throws IOException {
+        try {
+            storage.updateWithVersion(version, CONTEXT, key, value, expiration);
+            return true;
+        } catch (final VersionMismatchException e) {
+            return false;
+        }
+    }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/context/CredentialsContext.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/context/CredentialsContext.java
index 2e5edca..b1abd1a 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/context/CredentialsContext.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/context/CredentialsContext.java
@@ -16,6 +16,7 @@
 
 package org.geant.shibboleth.plugin.openidvci.messaging.context;
 
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.List;
 
@@ -49,6 +50,14 @@ public class CredentialsContext extends BaseContext {
     @Nullable
     private List<SignedJWT> proofs;
 
+    /** Issuance instant shared by all credentials of this request. */
+    @Nullable
+    private Instant issuedAt;
+
+    /** Expiration instant shared by all credentials of this request. */
+    @Nullable
+    private Instant expiresAt;
+
     /** Credential shell(s) per proof. */
     @Nullable
     private List<ClaimsSet> credentialShells;
@@ -84,6 +93,46 @@ public class CredentialsContext extends BaseContext {
         this.proofs = proofs;
     }
 
+    /**
+     * Get issuance instant shared by all credentials of this request.
+     *
+     * @return Issuance instant shared by all credentials of this request
+     */
+    @Nullable
+    public Instant getIssuedAt() {
+        return issuedAt;
+    }
+
+    /**
+     * Set issuance instant shared by all credentials of this request.
+     *
+     * @param issuanceInstant Issuance instant shared by all credentials of this
+     *                        request
+     */
+    public void setIssuedAt(@Nullable Instant issuanceInstant) {
+        this.issuedAt = issuanceInstant;
+    }
+
+    /**
+     * Get expiration instant shared by all credentials of this request.
+     *
+     * @return Expiration instant shared by all credentials of this request
+     */
+    @Nullable
+    public Instant getExpiresAt() {
+        return expiresAt;
+    }
+
+    /**
+     * Set expiration instant shared by all credentials of this request.
+     *
+     * @param expirationInstant Expiration instant shared by all credentials of this
+     *                          request
+     */
+    public void setExpiresAt(@Nullable Instant expirationInstant) {
+        this.expiresAt = expirationInstant;
+    }
+
     /**
      * Get credential content to for the final credential.
      * 
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/AddCredentialShell.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/AddCredentialShell.java
index 8b6f0d9..7f8a5cd 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/AddCredentialShell.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/AddCredentialShell.java
@@ -16,8 +16,7 @@
 package org.geant.shibboleth.plugin.openidvci.profile.impl;
 
 import java.text.ParseException;
-import java.time.Duration;
-import java.time.ZonedDateTime;
+import java.time.Instant;
 import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
 import java.util.List;
@@ -31,12 +30,12 @@ import net.shibboleth.idp.profile.AbstractProfileAction;
 import net.shibboleth.idp.profile.IdPEventIds;
 import net.shibboleth.profile.config.ProfileConfiguration;
 import net.shibboleth.profile.context.RelyingPartyContext;
-import net.shibboleth.profile.context.navigate.IssuerLookupFunction;
 import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
 import net.shibboleth.shared.component.ComponentInitializationException;
 import net.shibboleth.shared.logic.Constraint;
 import net.shibboleth.shared.primitive.LoggerFactory;
 
+import org.geant.shibboleth.plugin.oauth.profile.logic.CredentialIssuerLookupFunction;
 import org.geant.shibboleth.plugin.oauth.statuslist.context.StatusListContext;
 import org.geant.shibboleth.plugin.openidvci.config.OpenIDVCIConfiguration;
 import org.geant.shibboleth.plugin.openidvci.messaging.context.CredentialsContext;
@@ -86,8 +85,13 @@ public class AddCredentialShell extends AbstractProfileAction {
     @Nullable
     private RelyingPartyContext rpCtx;
 
-    /** Lifetime of credential. */
-    private Duration expiration;
+    /** Issuance instant shared by all credentials of this request. */
+    @Nullable
+    private Instant issuedAt;
+
+    /** Expiration instant shared by all credentials of this request. */
+    @Nullable
+    private Instant expiresAt;
 
     /**
      * Credential offer context.
@@ -102,7 +106,10 @@ public class AddCredentialShell extends AbstractProfileAction {
     /** Constructor. */
     public AddCredentialShell() {
         relyingPartyContextLookupStrategy = new ChildContextLookup<>(RelyingPartyContext.class);
-        issuerLookupStrategy = (Function<ProfileRequestContext, String>) new IssuerLookupFunction();
+        // The issuer is the did:jwk of the key that will sign, so that a credential
+        // cannot name an
+        // issuer whose key is not the one signing it.
+        issuerLookupStrategy = new CredentialIssuerLookupFunction();
     }
 
     /**
@@ -155,9 +162,7 @@ public class AddCredentialShell extends AbstractProfileAction {
             return false;
         }
         final ProfileConfiguration pc = rpCtx.getProfileConfig();
-        if (pc instanceof OpenIDVCIConfiguration) {
-            expiration = ((OpenIDVCIConfiguration) pc).getCredentialLifetime(profileRequestContext);
-        } else {
+        if (!(pc instanceof OpenIDVCIConfiguration)) {
             log.error("{} No OpenID VCI profile configuration associated with this profile request", getLogPrefix());
             ActionSupport.buildEvent(profileRequestContext, IdPEventIds.INVALID_RELYING_PARTY_CTX);
             return false;
@@ -170,12 +175,15 @@ public class AddCredentialShell extends AbstractProfileAction {
             return false;
         }
 
-        // TODO: Move issuer from CredentialDefinition if used here!
-        issuerId = ctx.getCredentialConfiguration().getCredentialDefinition() != null
-                ? ctx.getCredentialConfiguration().getCredentialDefinition().getIssuer()
-                : null;
-        issuerId = issuerId == null ? issuerLookupStrategy.apply(profileRequestContext) : issuerId;
+        issuedAt = ctx.getIssuedAt();
+        expiresAt = ctx.getExpiresAt();
+        if (issuedAt == null || expiresAt == null) {
+            log.error("{} Credential validity window has not been resolved for this profile request", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, IdPEventIds.INVALID_PROFILE_CONFIG);
+            return false;
+        }
 
+        issuerId = issuerLookupStrategy.apply(profileRequestContext);
         if (issuerId == null) {
             log.error("{} Unable to determine issuer to check audience", getLogPrefix());
             ActionSupport.buildEvent(profileRequestContext, IdPEventIds.INVALID_PROFILE_CONFIG);
@@ -196,10 +204,11 @@ public class AddCredentialShell extends AbstractProfileAction {
 
         List<ClaimsSet> shells = new ArrayList<>();
         if (ctx.getProofs() != null && !ctx.getProofs().isEmpty()) {
-            for (SignedJWT proof : ctx.getProofs()) {
-                ClaimsSet shell = createShell();
+            final List<SignedJWT> proofs = ctx.getProofs();
+            for (int ordinal = 0; ordinal < proofs.size(); ordinal++) {
+                ClaimsSet shell = createShell(ordinal);
                 try {
-                    Map<String, Object> cnf = buildCnf(proof);
+                    Map<String, Object> cnf = buildCnf(proofs.get(ordinal));
                     if (cnf != null) {
                         shell.setClaim("cnf", cnf);
                     }
@@ -211,15 +220,15 @@ public class AddCredentialShell extends AbstractProfileAction {
                 shells.add(shell);
             }
         } else {
-            shells.add(createShell());
+            shells.add(createShell(0));
         }
         ctx.setCredentialShells(shells);
     }
 
     /**
-     * Build cnf claim for response. Content is derived either from proof jwk header
-     * or kid value.
-     * 
+     * Build cnf claim for response. A did:jwk key reference from the proof kid
+     * header is preferred, an embedded proof jwk header is the fallback.
+     *
      * @param proof previously verified proof
      * @return cnf claim
      * @throws JsonProcessingException if something unexpected occurs.
@@ -227,41 +236,45 @@ public class AddCredentialShell extends AbstractProfileAction {
      */
     private Map<String, Object> buildCnf(SignedJWT proof) throws JsonProcessingException, ParseException {
 
+        final String kid = proof.getHeader().getKeyID();
+        if (kid != null && kid.startsWith("did:jwk:") && kid.endsWith("#0")) {
+            return Map.of("kid", kid);
+        }
         if (proof.getHeader().getJWK() != null) {
             return Map.of("jwk", proof.getHeader().getJWK().toJSONObject());
         }
-        if (proof.getHeader().getKeyID() != null && proof.getHeader().getKeyID().startsWith("did:jwk:")
-                && proof.getHeader().getKeyID().endsWith("#0")) {
-            return Map.of("kid", proof.getHeader().getKeyID());
-        }
         return null;
     }
 
     /**
      * Create the shell for credential.
-     * 
+     *
+     * @param ordinal position of the credential within this issuance, selecting the
+     *                status list slot allocated for it
      * @return credential shell.
      */
-    private ClaimsSet createShell() {
+    private ClaimsSet createShell(final int ordinal) {
         ClaimsSet shell = new ClaimsSet();
         shell.setIssuer(new Issuer(issuerId));
 
-        ZonedDateTime now = ZonedDateTime.now();
-        shell.setClaim("iat", now.toEpochSecond());
-        shell.setClaim("exp", now.toEpochSecond() + expiration.toSeconds());
+        shell.setClaim("iat", issuedAt.getEpochSecond());
+        shell.setClaim("exp", expiresAt.getEpochSecond());
         if ("dc+sd-jwt".equals(ctx.getCredentialConfiguration().getFormat())) {
             shell.setClaim("vct", ctx.getCredentialIdentifier());
         }
         if ("vc+sd-jwt".equals(ctx.getCredentialConfiguration().getFormat())) {
-            shell.setClaim("validFrom", DateTimeFormatter.ISO_INSTANT.format(now));
-            shell.setClaim("validUntil",
-                    DateTimeFormatter.ISO_INSTANT.format(now.plusSeconds(expiration.getSeconds())));
+            shell.setClaim("validFrom", DateTimeFormatter.ISO_INSTANT.format(issuedAt));
+            shell.setClaim("validUntil", DateTimeFormatter.ISO_INSTANT.format(expiresAt));
         }
-        if (statusListContext != null && statusListContext.getStatusListUrlPath() != null) {
-            // TODO: batch issuance (multiple proofs) currently gives every shell
-            // the SAME (idx, uri). Each shell must receive its own allocated slot.
-            shell.setClaim("status", Map.of("status_list", Map.of("idx", statusListContext.getIndex(), "uri",
-                    statusListContext.getStatusListUrlPath())));
+        if (statusListContext != null && statusListContext.getStatusListUri() != null) {
+            final Long index = statusListContext.getIndex(ordinal);
+            if (index != null) {
+                shell.setClaim("status",
+                        Map.of("status_list", Map.of("idx", index, "uri", statusListContext.getStatusListUri())));
+            } else {
+                log.warn("{} No status list slot allocated for credential {} of this issuance", getLogPrefix(),
+                        ordinal);
+            }
         }
         return shell;
     }
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormJsonLdSelectiveDisclosureJWTCredential.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormJsonLdSelectiveDisclosureJWTCredential.java
index b49625c..030c640 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormJsonLdSelectiveDisclosureJWTCredential.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormJsonLdSelectiveDisclosureJWTCredential.java
@@ -25,6 +25,7 @@ import java.util.function.Function;
 
 import javax.annotation.Nonnull;
 
+import org.geant.shibboleth.plugin.oauth.profile.logic.CredentialIssuerLookupFunction;
 import org.geant.shibboleth.plugin.openidvci.messaging.context.CredentialsContext;
 import org.geant.shibboleth.plugin.openidvci.messaging.impl.CredentialOfferRequestedClaim;
 import org.geant.shibboleth.plugin.openidvci.messaging.impl.CredentialOfferRequestedCredential;
@@ -37,7 +38,6 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import net.shibboleth.idp.profile.AbstractProfileAction;
-import net.shibboleth.profile.context.navigate.IssuerLookupFunction;
 import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
 import net.shibboleth.shared.logic.Constraint;
 
@@ -80,7 +80,7 @@ public class FormJsonLdSelectiveDisclosureJWTCredential extends AbstractProfileA
      * Constructor.
      */
     public FormJsonLdSelectiveDisclosureJWTCredential() {
-        issuerLookupStrategy = (Function<ProfileRequestContext, String>) new IssuerLookupFunction();
+        issuerLookupStrategy = new CredentialIssuerLookupFunction();
     }
 
     /**
@@ -150,10 +150,9 @@ public class FormJsonLdSelectiveDisclosureJWTCredential extends AbstractProfileA
                     : disclosures + "~" + sdActClaims.getFormattedDisclosures();
         }
         List<JWTClaimsSet> credentials = new ArrayList<>();
+        final String issuer = issuerLookupStrategy.apply(profileRequestContext);
         ctx.getCredentialShells().forEach(cred -> {
             try {
-                String issuer = ctx.getCredentialConfiguration().getCredentialDefinition().getIssuer();
-                issuer = issuer == null ? issuerLookupStrategy.apply(profileRequestContext) : issuer;
                 credentials.add(new JWTClaimsSet.Builder(cred.toJWTClaimsSet())
                         .claim("@context", ctx.getCredentialConfiguration().getCredentialDefinition().getContext())
                         .claim("id", "urn:uuid:" + UUID.randomUUID())
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ParseProof.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ParseProof.java
index 4eba047..d86ac67 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ParseProof.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ParseProof.java
@@ -131,6 +131,8 @@ public class ParseProof extends AbstractProfileAction {
             return;
         }
         List<SignedJWT> proofs = new ArrayList<SignedJWT>();
+        // TODO: Enforce a maximum number of proofs per request, aligned with the
+        // batch_credential_issuance.batch_size value advertised in issuer metadata.
         if (proofToken instanceof List<?> tokens) {
             tokens.forEach(token -> {
                 if (token instanceof String strToken) {
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ResolveCredentialLifetime.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ResolveCredentialLifetime.java
new file mode 100644
index 0000000..0ff21bb
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/ResolveCredentialLifetime.java
@@ -0,0 +1,145 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.geant.shibboleth.plugin.openidvci.profile.impl;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import net.shibboleth.idp.profile.AbstractProfileAction;
+import net.shibboleth.idp.profile.IdPEventIds;
+import net.shibboleth.profile.config.ProfileConfiguration;
+import net.shibboleth.profile.context.RelyingPartyContext;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+import org.geant.shibboleth.plugin.openidvci.config.OpenIDVCIConfiguration;
+import org.geant.shibboleth.plugin.openidvci.messaging.context.CredentialsContext;
+import org.opensaml.messaging.context.navigate.ChildContextLookup;
+import org.opensaml.profile.action.ActionSupport;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+/**
+ * Action that resolves the validity window shared by every credential issued from
+ * this request and sets it to work context {@link CredentialsContext} located
+ * under {@link ProfileRequestContext#getInboundMessageContext()}.
+ *
+ * Resolved once, so that all credentials of a batch have the same 'iat' and 'exp',
+ * and so that the status list chosen by the expiration matches the expiration that
+ * is issued. Credentials of one batch with different expirations could be
+ * recognized as siblings. An expiration resolved twice could also outlive the
+ * status list chosen for it.
+ */
+public class ResolveCredentialLifetime extends AbstractProfileAction {
+
+    /** Class logger. */
+    @Nonnull
+    private Logger log = LoggerFactory.getLogger(ResolveCredentialLifetime.class);
+
+    /**
+     * Strategy used to locate the {@link RelyingPartyContext} associated with a
+     * given {@link ProfileRequestContext}.
+     */
+    @Nonnull
+    private Function<ProfileRequestContext, RelyingPartyContext> relyingPartyContextLookupStrategy;
+
+    /** Lifetime of credential. */
+    @Nullable
+    private Duration lifetime;
+
+    /** Credentials context. */
+    @Nullable
+    private CredentialsContext ctx;
+
+    /** Constructor. */
+    public ResolveCredentialLifetime() {
+        relyingPartyContextLookupStrategy = new ChildContextLookup<>(RelyingPartyContext.class);
+    }
+
+    /**
+     * Set the strategy used to locate the {@link RelyingPartyContext} associated
+     * with a given {@link ProfileRequestContext}.
+     *
+     * @param strategy strategy used to locate the {@link RelyingPartyContext}
+     *                 associated with a given {@link ProfileRequestContext}
+     */
+    public void setRelyingPartyContextLookupStrategy(
+            @Nonnull final Function<ProfileRequestContext, RelyingPartyContext> strategy) {
+        ifInitializedThrowUnmodifiabledComponentException();
+
+        relyingPartyContextLookupStrategy = Constraint.isNotNull(strategy,
+                "RelyingPartyContext lookup strategy cannot be null");
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+
+        if (!super.doPreExecute(profileRequestContext)) {
+            return false;
+        }
+
+        final RelyingPartyContext rpCtx = relyingPartyContextLookupStrategy.apply(profileRequestContext);
+        if (rpCtx == null) {
+            log.debug("{} No relying party context associated with this profile request", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, IdPEventIds.INVALID_RELYING_PARTY_CTX);
+            return false;
+        }
+
+        final ProfileConfiguration pc = rpCtx.getProfileConfig();
+        if (!(pc instanceof OpenIDVCIConfiguration)) {
+            log.error("{} No OpenID VCI profile configuration associated with this profile request", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, IdPEventIds.INVALID_RELYING_PARTY_CTX);
+            return false;
+        }
+        lifetime = ((OpenIDVCIConfiguration) pc).getCredentialLifetime(profileRequestContext);
+        if (lifetime == null) {
+            log.error("{} No credential lifetime available", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, IdPEventIds.INVALID_PROFILE_CONFIG);
+            return false;
+        }
+
+        if (profileRequestContext.getInboundMessageContext() == null) {
+            log.error("{} No inbound message context", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+            return false;
+        }
+        ctx = profileRequestContext.getInboundMessageContext().ensureSubcontext(CredentialsContext.class);
+
+        return true;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+
+        assert ctx != null && lifetime != null;
+
+        final Instant issuedAt = Instant.now().truncatedTo(ChronoUnit.SECONDS);
+        ctx.setIssuedAt(issuedAt);
+        ctx.setExpiresAt(issuedAt.plus(lifetime));
+
+        log.debug("{} Credentials of this request are valid from {} until {}", getLogPrefix(), ctx.getIssuedAt(),
+                ctx.getExpiresAt());
+    }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/UnwrapGrant.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/UnwrapGrant.java
index dbb30e7..8e388d0 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/UnwrapGrant.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/UnwrapGrant.java
@@ -215,7 +215,7 @@ public class UnwrapGrant extends AbstractOpenIDVCITokenResponseAction {
                         .ensureSubcontext(TokenContext.class);
                 tokenContext.setCredentialOfferObject(credentialOfferCache.getCredentialOffer(code));
                 setPotentialCredentials(tokenContext);
-                credentialOfferCache.removeCredentialOffer(code);
+                //credentialOfferCache.removeCredentialOffer(code);
                 // Subject and auth time set for access token creation. TODO make sure what
                 // content to use.
                 getOidcResponseContext().setSubject(clientIDLookupStrategy.apply(profileRequestContext).getValue());
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/CredentialCountLookupFunction.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/CredentialCountLookupFunction.java
new file mode 100644
index 0000000..be790ab
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/CredentialCountLookupFunction.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.openidvci.profile.logic;
+
+import java.util.List;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.geant.shibboleth.plugin.openidvci.messaging.context.CredentialsContext;
+import org.opensaml.profile.context.ProfileRequestContext;
+
+import com.nimbusds.jwt.SignedJWT;
+
+/**
+ * Supplies the number of credentials a request will produce, being one per
+ * validated proof in {@link CredentialsContext}.
+ *
+ * A request without proofs gives one credential without key binding, so the
+ * count is one.
+ */
+public class CredentialCountLookupFunction implements Function<ProfileRequestContext, Integer> {
+
+    /** {@inheritDoc} */
+    @Override
+    @Nonnull
+    public Integer apply(@Nullable final ProfileRequestContext profileRequestContext) {
+        if (profileRequestContext == null || profileRequestContext.getInboundMessageContext() == null) {
+            return 1;
+        }
+
+        final CredentialsContext ctx = profileRequestContext.getInboundMessageContext()
+                .getSubcontext(CredentialsContext.class);
+        if (ctx == null) {
+            return 1;
+        }
+
+        final List<SignedJWT> proofs = ctx.getProofs();
+
+        return proofs == null || proofs.isEmpty() ? 1 : proofs.size();
+    }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/CredentialDetailsLookupFunction.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/CredentialDetailsLookupFunction.java
new file mode 100644
index 0000000..d069dac
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/CredentialDetailsLookupFunction.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.openidvci.profile.logic;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.geant.shibboleth.plugin.openidvci.messaging.context.CredentialsContext;
+import org.geant.shibboleth.plugin.openidvci.messaging.impl.CredentialOfferRequestedClaim;
+import org.opensaml.profile.context.ProfileRequestContext;
+
+/**
+ * Describes an issued credential for the record of the status list slots it took.
+ *
+ * This is all that OpenID VCI does for recording an assignment. The status list side
+ * knows nothing about credentials.
+ *
+ * Claims are reported by path with their values, a claim of a nested object as
+ * 'address.locality'. Values are taken from what was requested to be issued. The
+ * credential itself has only digests of them.
+ */
+public class CredentialDetailsLookupFunction implements Function<ProfileRequestContext, Map<String, Object>> {
+
+    /** {@inheritDoc} */
+    @Override
+    @Nonnull
+    public Map<String, Object> apply(@Nullable final ProfileRequestContext profileRequestContext) {
+        final Map<String, Object> details = new HashMap<>();
+
+        if (profileRequestContext == null || profileRequestContext.getInboundMessageContext() == null) {
+            return details;
+        }
+        final CredentialsContext ctx = profileRequestContext.getInboundMessageContext()
+                .getSubcontext(CredentialsContext.class);
+        if (ctx == null) {
+            return details;
+        }
+
+        details.put("credential_configuration_id", ctx.getCredentialIdentifier());
+        if (ctx.getCredentialConfiguration() != null) {
+            details.put("format", ctx.getCredentialConfiguration().getFormat());
+        }
+        details.put("claims", claims(ctx));
+
+        return details;
+    }
+
+    /**
+     * Collect the claims of the credential by path.
+     *
+     * @param ctx credentials context to read
+     * @return claims by path
+     */
+    @Nonnull
+    private Map<String, Object> claims(@Nonnull final CredentialsContext ctx) {
+        final Map<String, Object> claims = new HashMap<>();
+
+        if (ctx.getCredentialContent() == null || ctx.getCredentialContent().getRequestedCredential() == null) {
+            return claims;
+        }
+
+        for (final CredentialOfferRequestedClaim claim : ctx.getCredentialContent().getRequestedCredential()) {
+            final List<String> path = claim.getPath();
+            if (path == null || path.isEmpty()) {
+                continue;
+            }
+            claims.put(String.join(".", path), claim.getValue());
+        }
+
+        return claims;
+    }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/CredentialExpirationLookupFunction.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/CredentialExpirationLookupFunction.java
new file mode 100644
index 0000000..8205afc
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/CredentialExpirationLookupFunction.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.openidvci.profile.logic;
+
+import java.time.Instant;
+import java.util.function.Function;
+
+import javax.annotation.Nullable;
+
+import org.geant.shibboleth.plugin.openidvci.messaging.context.CredentialsContext;
+import org.opensaml.profile.context.ProfileRequestContext;
+
+/**
+ * Supplies the expiration shared by the credentials of a request, as resolved
+ * into {@link CredentialsContext}.
+ *
+ * Returns null if no expiration has been resolved. The caller decides if that
+ * is an error.
+ */
+public class CredentialExpirationLookupFunction implements Function<ProfileRequestContext, Instant> {
+
+    /** {@inheritDoc} */
+    @Override
+    @Nullable
+    public Instant apply(@Nullable final ProfileRequestContext profileRequestContext) {
+        if (profileRequestContext == null || profileRequestContext.getInboundMessageContext() == null) {
+            return null;
+        }
+
+        final CredentialsContext ctx = profileRequestContext.getInboundMessageContext()
+                .getSubcontext(CredentialsContext.class);
+
+        return ctx == null ? null : ctx.getExpiresAt();
+    }
+
+}
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/assign/assign-beans.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/assign/assign-beans.xml
index edab3aa..3c55919 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/assign/assign-beans.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/assign/assign-beans.xml
@@ -14,12 +14,40 @@
   <bean id="oauth.StatusListIndexAllocator"
         class="org.geant.shibboleth.plugin.oauth.statuslist.storage.StatusListIndexAllocator"
         depends-on="shibboleth.LoggingService"
-        p:storage-ref="#{'%{oauth.StatusListIndexAllocator:shibboleth.StorageService}'.trim()}" />
+        p:id="oauth.StatusListIndexAllocator"
+        p:storage-ref="#{'%{oauth.StatusListIndexAllocator:shibboleth.StorageService}'.trim()}"
+        p:permutationKey="%{oauth.statuslist.indexPermutationKey:}" />
 
   <bean id="AllocateIndex"
         class="org.geant.shibboleth.plugin.oauth.statuslist.profile.impl.AllocateIndex"
         scope="prototype"
         p:statusListAllocator-ref="oauth.StatusListIndexAllocator"
-        p:httpServletRequestSupplier-ref="shibboleth.HttpServletRequestSupplier" />
+        p:httpServletRequestSupplier-ref="shibboleth.HttpServletRequestSupplier">
+    <property name="expirationLookupStrategy">
+      <bean class="org.geant.shibboleth.plugin.openidvci.profile.logic.CredentialExpirationLookupFunction" />
+    </property>
+    <property name="indexCountLookupStrategy">
+      <bean class="org.geant.shibboleth.plugin.openidvci.profile.logic.CredentialCountLookupFunction" />
+    </property>
+  </bean>
+
+  <bean id="oauth.StatusListAssignmentStore"
+        class="org.geant.shibboleth.plugin.oauth.statuslist.storage.StatusListAssignmentStore"
+        depends-on="shibboleth.LoggingService"
+        p:id="oauth.StatusListAssignmentStore"
+        p:retention="%{oauth.statuslist.assignmentRetention:P7D}"
+        p:storage-ref="#{'%{oauth.StatusListAssignmentStore:shibboleth.StorageService}'.trim()}" />
+
+  <bean id="AssignIndex"
+        class="org.geant.shibboleth.plugin.oauth.statuslist.profile.impl.AssignIndex"
+        scope="prototype"
+        p:statusListAssignmentStore-ref="oauth.StatusListAssignmentStore">
+    <property name="expirationLookupStrategy">
+      <bean class="org.geant.shibboleth.plugin.openidvci.profile.logic.CredentialExpirationLookupFunction" />
+    </property>
+    <property name="detailsLookupStrategy">
+      <bean class="org.geant.shibboleth.plugin.openidvci.profile.logic.CredentialDetailsLookupFunction" />
+    </property>
+  </bean>
 
 </beans>
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/assignments/assignments-beans.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/assignments/assignments-beans.xml
new file mode 100644
index 0000000..520dfa2
--- /dev/null
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/assignments/assignments-beans.xml
@@ -0,0 +1,61 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<beans
+ xmlns="http://www.springframework.org/schema/beans"
+ xmlns:c="http://www.springframework.org/schema/c"
+ xmlns:context="http://www.springframework.org/schema/context"
+ xmlns:p="http://www.springframework.org/schema/p"
+ xmlns:util="http://www.springframework.org/schema/util"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
+                           http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd
+                           http://www.springframework.org/schema/util http://www.springframework.org/schema/util/spring-util.xsd"
+       default-init-method="initialize" default-destroy-method="destroy">
+
+  <bean id="shibboleth.openidvci.profileId" class="java.lang.String"
+        c:_0="http://geant.org/ns/profiles/oauth/status-list/assignments"/>
+
+  <bean id="DecodeMessage" class="org.opensaml.profile.action.impl.DecodeMessage" scope="prototype">
+    <constructor-arg>
+      <bean class="org.geant.shibboleth.plugin.oauth.statuslist.decoding.impl.StatusListAssignmentsRequestDecoder"
+            scope="prototype" p:httpServletRequestSupplier-ref="shibboleth.HttpServletRequestSupplier" />
+    </constructor-arg>
+  </bean>
+
+  <bean id="InitializeAuthenticationContext"
+        class="org.geant.shibboleth.plugin.openidvci.profile.impl.InitializeAuthenticationContext"
+        scope="prototype" />
+
+  <bean id="shibboleth.ClientIDLookupStrategy"
+        class="net.shibboleth.idp.plugin.oidc.op.messaging.context.navigate.TokenRequestClientIDLookupFunction" />
+
+  <bean id="oauth.StatusListAssignmentStore"
+        class="org.geant.shibboleth.plugin.oauth.statuslist.storage.StatusListAssignmentStore"
+        depends-on="shibboleth.LoggingService"
+        p:id="oauth.StatusListAssignmentStore"
+        p:retention="%{oauth.statuslist.assignmentRetention:P7D}"
+        p:storage-ref="#{'%{oauth.StatusListAssignmentStore:shibboleth.StorageService}'.trim()}" />
+
+  <bean id="FormOutboundMessage"
+        class="org.geant.shibboleth.plugin.oauth.statuslist.profile.impl.FormOutboundStatusListAssignmentsResponseMessage"
+        scope="prototype"
+        p:statusListAssignmentStore-ref="oauth.StatusListAssignmentStore" />
+
+  <bean id="BuildErrorResponseFromEvent" class="net.shibboleth.oidc.profile.impl.BuildJSONErrorResponseFromEvent"
+        scope="prototype" p:defaultStatusCode="400" p:defaultCode="invalid_request"
+        p:httpServletResponseSupplier-ref="shibboleth.HttpServletResponseSupplier">
+    <property name="eventContextLookupStrategy">
+      <bean class="net.shibboleth.idp.profile.context.navigate.WebFlowCurrentEventLookupFunction" />
+    </property>
+    <property name="mappedErrors">
+      <map value-type="com.nimbusds.oauth2.sdk.ErrorObject">
+        <entry key="#{T(org.opensaml.profile.action.EventIds).INVALID_MSG_CTX}"
+               value="#{T(com.nimbusds.oauth2.sdk.OAuth2Error).INVALID_REQUEST}" />
+        <entry key="#{T(org.opensaml.profile.action.EventIds).ACCESS_DENIED}"
+               value="#{T(com.nimbusds.oauth2.sdk.OAuth2Error).INVALID_CLIENT}" />
+        <entry key="#{T(org.opensaml.profile.action.EventIds).IO_ERROR}"
+               value="#{T(com.nimbusds.oauth2.sdk.OAuth2Error).SERVER_ERROR}" />
+      </map>
+    </property>
+  </bean>
+
+</beans>
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/assignments/assignments-flow.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/assignments/assignments-flow.xml
new file mode 100644
index 0000000..616c013
--- /dev/null
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/assignments/assignments-flow.xml
@@ -0,0 +1,26 @@
+<?xml version="1.0"?>
+<flow xmlns="http://www.springframework.org/schema/webflow"
+      xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+      xsi:schemaLocation="http://www.springframework.org/schema/webflow http://www.springframework.org/schema/webflow/spring-webflow.xsd"
+      parent="openid/vci/abstract-api">
+
+  <action-state id="InitializeMandatoryContexts">
+    <evaluate expression="InitializeProfileRequestContext" />
+    <evaluate expression="PopulateMetricContext" />
+    <evaluate expression="FlowStartPopulateAuditContext" />
+    <evaluate expression="InitializeOutboundMessageContext" />
+    <evaluate expression="'proceed'" />
+
+    <transition on="proceed" to="DecodeMessage" />
+  </action-state>
+
+  <action-state id="ResumeAfterAuthentication">
+    <evaluate expression="'proceed'" />
+
+    <transition on="proceed" to="BuildResponseMessage" />
+  </action-state>
+
+  <bean-import
+    resource="classpath:/META-INF/net/shibboleth/idp/flows/oauth/status-list/assignments/assignments-beans.xml" />
+
+</flow>
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/list/list-beans.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/list/list-beans.xml
new file mode 100644
index 0000000..7e54537
--- /dev/null
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/list/list-beans.xml
@@ -0,0 +1,86 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<beans
+ xmlns="http://www.springframework.org/schema/beans"
+ xmlns:c="http://www.springframework.org/schema/c"
+ xmlns:context="http://www.springframework.org/schema/context"
+ xmlns:p="http://www.springframework.org/schema/p"
+ xmlns:util="http://www.springframework.org/schema/util"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
+       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
+                           http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd
+                           http://www.springframework.org/schema/util http://www.springframework.org/schema/util/spring-util.xsd" 
+       default-init-method="initialize" default-destroy-method="destroy">
+       
+  <bean id="shibboleth.openidvci.profileId" class="java.lang.String" c:_0="http://geant.org/ns/profiles/oauth/status-list"/>
+  
+  <bean id="InitializeUnverifiedRelyingPartyContext"
+        class="net.shibboleth.idp.plugin.oidc.op.profile.impl.InitializeUnverifiedRelyingPartyContext" scope="prototype" />
+
+  <bean id="PopulateStatusListSignatureSigningParameters"
+        class="net.shibboleth.oidc.profile.impl.PopulateJWTSignatureSigningParameters"
+        scope="prototype" p:noResultIsError="true"
+        c:strategy-ref="shibboleth.MessageContextLookup.Outbound">
+    <property name="configurationLookupStrategy">
+      <bean lazy-init="true"
+            class="net.shibboleth.oidc.profile.config.navigate.JWTSignatureSigningConfigurationLookupFunction" />
+    </property>
+    <!-- CHANGE TO Basic and configure sec conf having correct type of key only-->
+    <!-- Security configuration in general needs lot of work still -->
+    <property name="signatureSigningParametersResolver">
+            <bean class="net.shibboleth.oidc.security.jose.impl.ClientInformationSignatureSigningParametersResolver">
+                <constructor-arg name="signatureAlgorithmLookupStrategy">
+                    <bean
+                        class="net.shibboleth.oidc.profile.config.navigate.ClientInformationStringValueLookupFunction"
+                        c:keyName="id_token_signed_response_alg" />
+                </constructor-arg>
+                <constructor-arg name="defaultAlgorithmValue" value="ES256" />
+            </bean>
+    </property>
+  </bean>
+    
+  <bean id="openidvci.IssuerLookupStrategy"
+        class="org.geant.shibboleth.plugin.oauth.profile.logic.CredentialIssuerLookupFunction"
+        p:issuer="%{openidvci.issuer:}" />
+
+  <bean id="oauth.StatusListStore"
+        class="org.geant.shibboleth.plugin.oauth.statuslist.storage.StatusListStore"
+        depends-on="shibboleth.LoggingService"
+        p:id="oauth.StatusListStore"
+        p:storage-ref="#{'%{oauth.StatusListStore:shibboleth.StorageService}'.trim()}" />
+
+  <bean id="FormStatusListToken"
+        class="org.geant.shibboleth.plugin.oauth.statuslist.profile.impl.FormStatusListToken"
+        scope="prototype"
+        p:statusListStore-ref="oauth.StatusListStore"
+        p:ttl="%{oauth.statuslist.ttl:PT1H}"
+        p:issuerLookupStrategy-ref="openidvci.IssuerLookupStrategy"
+        p:httpServletRequestSupplier-ref="shibboleth.HttpServletRequestSupplier" />
+
+  <bean id="SignStatusListToken"
+        class="org.geant.shibboleth.plugin.oauth.statuslist.profile.impl.SignStatusListToken"
+        scope="prototype" />
+
+  <bean id="FormOutboundMessage"
+        class="org.geant.shibboleth.plugin.oauth.statuslist.profile.impl.FormOutboundStatusListResponseMessage"
+        scope="prototype" />
+  <bean id="BuildErrorResponseFromEvent" class="net.shibboleth.oidc.profile.impl.BuildJSONErrorResponseFromEvent" 
+        scope="prototype" p:defaultStatusCode="400" p:defaultCode="invalid_request"
+        p:httpServletResponseSupplier-ref="shibboleth.HttpServletResponseSupplier">
+    <property name="eventContextLookupStrategy">
+      <bean class="net.shibboleth.idp.profile.context.navigate.WebFlowCurrentEventLookupFunction" />
+    </property>
+    <property name="mappedErrors">
+      <map value-type="com.nimbusds.oauth2.sdk.ErrorObject">
+        <!-- Unknown list is 404 and a retired list is 410, so a verifier can tell them apart. -->
+        <entry key="#{T(org.geant.shibboleth.plugin.oauth.statuslist.profile.StatusListEventIds).NO_STATUS_LIST}"
+               value="#{T(org.geant.shibboleth.plugin.oauth.statuslist.messaging.error.StatusListError).UNKNOWN_STATUS_LIST}" />
+        <entry key="#{T(org.geant.shibboleth.plugin.oauth.statuslist.profile.StatusListEventIds).STATUS_LIST_RETIRED}"
+               value="#{T(org.geant.shibboleth.plugin.oauth.statuslist.messaging.error.StatusListError).RETIRED_STATUS_LIST}" />
+        <entry key="#{T(org.opensaml.profile.action.EventIds).IO_ERROR}"
+               value="#{T(com.nimbusds.oauth2.sdk.OAuth2Error).SERVER_ERROR}" />
+      </map>
+    </property>
+  </bean>
+
+  
+</beans>
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/nonce/nonce-flow.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/list/list-flow.xml
similarity index 76%
copy from openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/nonce/nonce-flow.xml
copy to openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/list/list-flow.xml
index bfe9ebd..7b2d3f8 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/nonce/nonce-flow.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/list/list-flow.xml
@@ -12,10 +12,13 @@
     <evaluate expression="InitializeUnverifiedRelyingPartyContext" />
     <evaluate expression="SelectRelyingPartyConfiguration" />
     <evaluate expression="SelectProfileConfiguration" />
+    <evaluate expression="PopulateStatusListSignatureSigningParameters" />
+    <evaluate expression="FormStatusListToken" />
+    <evaluate expression="SignStatusListToken" />
     <evaluate expression="'proceed'"/>
     <transition on="proceed" to="BuildResponseMessage"/>
   </action-state>
 
-  <bean-import resource="nonce-beans.xml"/>
+  <bean-import resource="classpath:/META-INF/net/shibboleth/idp/flows/oauth/status-list/list/list-beans.xml"/>
 
 </flow>
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/revoke/revoke-beans.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/revoke/revoke-beans.xml
new file mode 100644
index 0000000..10deaed
--- /dev/null
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/revoke/revoke-beans.xml
@@ -0,0 +1,65 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<beans
+ xmlns="http://www.springframework.org/schema/beans"
+ xmlns:c="http://www.springframework.org/schema/c"
+ xmlns:context="http://www.springframework.org/schema/context"
+ xmlns:p="http://www.springframework.org/schema/p"
+ xmlns:util="http://www.springframework.org/schema/util"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
+                           http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd
+                           http://www.springframework.org/schema/util http://www.springframework.org/schema/util/spring-util.xsd"
+       default-init-method="initialize" default-destroy-method="destroy">
+
+  <bean id="shibboleth.openidvci.profileId" class="java.lang.String"
+        c:_0="http://geant.org/ns/profiles/oauth/status-list/revoke"/>
+
+  <bean id="DecodeMessage" class="org.opensaml.profile.action.impl.DecodeMessage" scope="prototype">
+    <constructor-arg>
+      <bean class="org.geant.shibboleth.plugin.oauth.statuslist.decoding.impl.StatusListRevokeRequestDecoder"
+            scope="prototype" p:httpServletRequestSupplier-ref="shibboleth.HttpServletRequestSupplier" />
+    </constructor-arg>
+  </bean>
+
+  <bean id="InitializeAuthenticationContext"
+        class="org.geant.shibboleth.plugin.openidvci.profile.impl.InitializeAuthenticationContext"
+        scope="prototype" />
+
+  <bean id="shibboleth.ClientIDLookupStrategy"
+        class="net.shibboleth.idp.plugin.oidc.op.messaging.context.navigate.TokenRequestClientIDLookupFunction" />
+
+  <bean id="oauth.StatusListStore"
+        class="org.geant.shibboleth.plugin.oauth.statuslist.storage.StatusListStore"
+        depends-on="shibboleth.LoggingService"
+        p:id="oauth.StatusListStore"
+        p:storage-ref="#{'%{oauth.StatusListStore:shibboleth.StorageService}'.trim()}" />
+
+  <bean id="RevokeStatusListIndex"
+        class="org.geant.shibboleth.plugin.oauth.statuslist.profile.impl.RevokeStatusListIndex"
+        scope="prototype"
+        p:statusListStore-ref="oauth.StatusListStore" />
+
+  <bean id="FormOutboundMessage"
+        class="org.geant.shibboleth.plugin.oauth.statuslist.profile.impl.FormOutboundStatusListRevokeResponseMessage"
+        scope="prototype" />
+
+  <bean id="BuildErrorResponseFromEvent" class="net.shibboleth.oidc.profile.impl.BuildJSONErrorResponseFromEvent"
+        scope="prototype" p:defaultStatusCode="400" p:defaultCode="invalid_request"
+        p:httpServletResponseSupplier-ref="shibboleth.HttpServletResponseSupplier">
+    <property name="eventContextLookupStrategy">
+      <bean class="net.shibboleth.idp.profile.context.navigate.WebFlowCurrentEventLookupFunction" />
+    </property>
+    <property name="mappedErrors">
+      <map value-type="com.nimbusds.oauth2.sdk.ErrorObject">
+        <entry key="#{T(org.opensaml.profile.action.EventIds).INVALID_MSG_CTX}"
+               value="#{T(com.nimbusds.oauth2.sdk.OAuth2Error).INVALID_REQUEST}" />
+        <entry key="#{T(org.opensaml.profile.action.EventIds).ACCESS_DENIED}"
+               value="#{T(com.nimbusds.oauth2.sdk.OAuth2Error).INVALID_CLIENT}" />
+        <!-- Storage refused the write, so the slot is not revoked and the caller has to retry. -->
+        <entry key="#{T(org.opensaml.profile.action.EventIds).IO_ERROR}"
+               value="#{T(com.nimbusds.oauth2.sdk.OAuth2Error).SERVER_ERROR}" />
+      </map>
+    </property>
+  </bean>
+
+</beans>
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/revoke/revoke-flow.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/revoke/revoke-flow.xml
new file mode 100644
index 0000000..897e765
--- /dev/null
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/revoke/revoke-flow.xml
@@ -0,0 +1,26 @@
+<?xml version="1.0"?>
+<flow xmlns="http://www.springframework.org/schema/webflow"
+      xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+      xsi:schemaLocation="http://www.springframework.org/schema/webflow http://www.springframework.org/schema/webflow/spring-webflow.xsd"
+      parent="openid/vci/abstract-api">
+
+  <action-state id="InitializeMandatoryContexts">
+    <evaluate expression="InitializeProfileRequestContext" />
+    <evaluate expression="PopulateMetricContext" />
+    <evaluate expression="FlowStartPopulateAuditContext" />
+    <evaluate expression="InitializeOutboundMessageContext" />
+    <evaluate expression="'proceed'" />
+
+    <transition on="proceed" to="DecodeMessage" />
+  </action-state>
+
+  <action-state id="ResumeAfterAuthentication">
+    <evaluate expression="RevokeStatusListIndex" />
+    <evaluate expression="'proceed'" />
+
+    <transition on="proceed" to="BuildResponseMessage" />
+  </action-state>
+
+  <bean-import resource="classpath:/META-INF/net/shibboleth/idp/flows/oauth/status-list/revoke/revoke-beans.xml" />
+
+</flow>
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/abstract-api/abstract-api-flow.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/abstract-api/abstract-api-flow.xml
index 67a33cb..5255c2b 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/abstract-api/abstract-api-flow.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/abstract-api/abstract-api-flow.xml
@@ -62,5 +62,5 @@
     </on-entry>
   </end-state>
 
-  <bean-import resource="../abstract-api/abstract-api-beans.xml"/>
+  <bean-import resource="classpath:/META-INF/net/shibboleth/idp/flows/openid/vci/abstract-api/abstract-api-beans.xml"/>
 </flow>
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/abstract/abstract-flow.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/abstract/abstract-flow.xml
index 3261b86..6f37da9 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/abstract/abstract-flow.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/abstract/abstract-flow.xml
@@ -16,6 +16,7 @@
     <transition on-exception="java.lang.RuntimeException" to="LogRuntimeException"/>
     <transition on="#{!'proceed'.equals(currentEvent.id)}" to="HandleError"/>
   </global-transitions>
-  <bean-import resource="../abstract/abstract-beans.xml"/>
+
+  <bean-import resource="classpath:/META-INF/net/shibboleth/idp/flows/openid/vci/abstract/abstract-beans.xml"/>
 
 </flow>
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credential-offer/credential-offer-flow.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credential-offer/credential-offer-flow.xml
index 25b765e..c456a4d 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credential-offer/credential-offer-flow.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credential-offer/credential-offer-flow.xml
@@ -22,6 +22,6 @@
     <transition on="proceed" to="BuildResponseMessage"/>
   </action-state>
 
-  <bean-import resource="credential-offer-beans.xml"/>
+  <bean-import resource="classpath:/META-INF/net/shibboleth/idp/flows/openid/vci/credential-offer/credential-offer-beans.xml"/>
 
 </flow>
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-beans.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-beans.xml
index 69667ba..254cc31 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-beans.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-beans.xml
@@ -134,14 +134,21 @@
         </property>
   </bean>
     
-  <bean id="AddCredentialShell" class="org.geant.shibboleth.plugin.openidvci.profile.impl.AddCredentialShell"
+  <bean id="ResolveCredentialLifetime" class="org.geant.shibboleth.plugin.openidvci.profile.impl.ResolveCredentialLifetime"
         scope="prototype" />
-        
+
+  <bean id="openidvci.IssuerLookupStrategy"
+        class="org.geant.shibboleth.plugin.oauth.profile.logic.CredentialIssuerLookupFunction"
+        p:issuer="%{openidvci.issuer:}" />
+
+  <bean id="AddCredentialShell" class="org.geant.shibboleth.plugin.openidvci.profile.impl.AddCredentialShell"
+        scope="prototype" p:issuerLookupStrategy-ref="openidvci.IssuerLookupStrategy" />
+
   <bean id="OptionallyFormSelectiveDisclosureJWTCredential" class="org.geant.shibboleth.plugin.openidvci.profile.impl.FormSelectiveDisclosureJWTCredential"
         scope="prototype" />
-        
+
   <bean id="OptionallyFormJsonLdSelectiveDisclosureJWTCredential" class="org.geant.shibboleth.plugin.openidvci.profile.impl.FormJsonLdSelectiveDisclosureJWTCredential"
-        scope="prototype" />
+        scope="prototype" p:issuerLookupStrategy-ref="openidvci.IssuerLookupStrategy" />
   
   <bean id="SignJWTCredential" class="org.geant.shibboleth.plugin.openidvci.profile.impl.SignJWTCredential"
         scope="prototype" p:issuerLookupStrategy-ref="shibboleth.ResponderIdLookup.Simple" >
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-flow.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-flow.xml
index fad314f..46d71fd 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-flow.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-flow.xml
@@ -32,11 +32,18 @@
     <evaluate expression="PopulateProofSignatureValidationParameters" />
     <evaluate expression="ValidateProofSignature" />
     <evaluate expression="PopulateCredentialsSignatureSigningParameters" />
+    <!-- Fixes iat/exp for all credentials of this request. The status list bucket comes from it. -->
+    <evaluate expression="ResolveCredentialLifetime" />
     <evaluate expression="'proceed'" />
     <transition on="proceed" to="AllocateStatusListIndex" />
   </action-state>
 
   <action-state id="ResumeAfterAllocateStatusListIndex">
+    <evaluate expression="'proceed'" />
+    <transition on="proceed" to="AssignStatusListIndex" />
+  </action-state>
+
+  <action-state id="ResumeAfterAssignStatusListIndex">
     <evaluate expression="'proceed'" />
     <transition on="proceed" to="BuildResponse" />
   </action-state>
@@ -50,6 +57,6 @@
     <transition on="proceed" to="BuildResponseMessage" />
   </action-state>
 
-  <bean-import resource="credentials-beans.xml" />
+  <bean-import resource="classpath:/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-beans.xml" />
 
 </flow>
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/nonce/nonce-flow.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/nonce/nonce-flow.xml
index bfe9ebd..3e1ab2a 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/nonce/nonce-flow.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/nonce/nonce-flow.xml
@@ -16,6 +16,6 @@
     <transition on="proceed" to="BuildResponseMessage"/>
   </action-state>
 
-  <bean-import resource="nonce-beans.xml"/>
+  <bean-import resource="classpath:/META-INF/net/shibboleth/idp/flows/openid/vci/nonce/nonce-beans.xml"/>
 
 </flow>
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/token/token-flow.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/token/token-flow.xml
index 6cc1f16..3eb8e61 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/token/token-flow.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/token/token-flow.xml
@@ -74,6 +74,6 @@
   </action-state>
 
 
-  <bean-import resource="token-beans.xml"/>
+  <bean-import resource="classpath:/META-INF/net/shibboleth/idp/flows/openid/vci/token/token-beans.xml"/>
 
 </flow>
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/service/relying-party/postconfig.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/service/relying-party/postconfig.xml
index 053013b..50f4861 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/service/relying-party/postconfig.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/service/relying-party/postconfig.xml
@@ -46,7 +46,17 @@
         
     <bean id="OpenID.VCI.Credentials" parent="AbstractVCIProfile" lazy-init="true"
         class="org.geant.shibboleth.plugin.openidvci.config.impl.DefaultOpenIDVCICredentialsConfiguration" />
-    
+
+    <bean id="OAuth.StatusList" lazy-init="true"
+        class="org.geant.shibboleth.plugin.oauth.statuslist.config.DefaultStatusListConfiguration"
+        p:securityConfiguration-ref="%{idp.security.oidc.config:shibboleth.oidc.DefaultSecurityConfiguration}" />
+
+    <bean id="OAuth.StatusList.Revoke" parent="AbstractVCIProfile" lazy-init="true"
+        class="org.geant.shibboleth.plugin.oauth.statuslist.config.DefaultStatusListRevokeConfiguration" />
+
+    <bean id="OAuth.StatusList.Assignments" parent="AbstractVCIProfile" lazy-init="true"
+        class="org.geant.shibboleth.plugin.oauth.statuslist.config.DefaultStatusListAssignmentsConfiguration" />
+
     <!-- The generator does not tie client id to nonce to accomodate unprotected Nonce endpoint. -->    
     <bean id="openidvci.DefaultOpenIDVCINonceGenerator" parent="DefaultOAuth2DPoPNonceGenerator" lazy-init="true"
         p:relyingPartyIdLookupStrategy-ref="openidvci.RelyingPartyForNonce">
diff --git a/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci.properties b/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci.properties
index fc6bb0e..4ee1201 100644
--- a/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci.properties
+++ b/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci.properties
@@ -1 +1,54 @@
-#placeholder
\ No newline at end of file
+# Properties for the OpenID for Verifiable Credential Issuance plugin.
+#
+# Installed as conf/openid-vci.properties and read with the rest of conf.
+
+
+# Key that spreads status list indices across a list, so that an issued credential does not tell
+# its place in the issuance order.
+#
+# Change this. The value below is shipped with the plugin and anyone can use it to recover the
+# issuance order of the credentials.
+#
+# After that do not change it again. Indices are derived from this key. With two keys the same
+# status list slot can be issued twice.
+oauth.statuslist.indexPermutationKey=change_this_to_secret
+
+
+# How long a published status list may be cached, as the ttl claim of the token.
+#
+# This is also how long a revocation can stay unnoticed. A verifier with a cached list does not see
+# it before the cache expires. A shorter time means lists are asked more often. A list is only a few
+# hundred bytes until something is revoked in it.
+oauth.statuslist.ttl=PT1H
+
+
+# How long a record of a status list assignment waits in storage to be collected.
+#
+# A status list cannot be searched back to the credentials it describes, so every issuance writes a
+# record of the slots it took and of the claims issued, in plain text. The record is for a collector
+# outside the IdP, for example an identity management system, which is where the permanent record of
+# issued credentials belongs.
+#
+# This is how long a record survives if no collector comes for it. Long enough that an outage does
+# not leave credentials that cannot be revoked, and no longer than that. The credential itself has
+# only digests of the claims.
+#
+# Failing to write a record fails the issuance.
+oauth.statuslist.assignmentRetention=P7D
+
+
+# Issuer identifier of issued credentials and of the status lists published for them.
+#
+# Empty means the issuer is the did:jwk of the signing key. Nothing needs to be published and the
+# two cannot disagree, as a did:jwk identifier is the key itself.
+#
+# A set value is used as it is. This is how a did:web issuer is chosen, for example
+# did:web:example.org:vci. Then a DID document must be published, for this example at
+# https://example.org/vci/did.json, naming the signing key as a verification method, and the kid of
+# the signing credential must be set to that verification method. Nothing here resolves the
+# document, so keeping the document, the key and the kid in agreement is up to the deployer. A
+# mismatch makes every credential unverifiable and there is no local symptom of it.
+#
+# The choice is deployment wide. One status list serves every credential expiring in the same month
+# whatever the configuration, and a status list token names one issuer only.
+openidvci.issuer=
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/oauth/profile/logic/CredentialIssuerLookupFunctionTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/oauth/profile/logic/CredentialIssuerLookupFunctionTest.java
new file mode 100644
index 0000000..8bf475c
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/oauth/profile/logic/CredentialIssuerLookupFunctionTest.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.oauth.profile.logic;
+
+import java.security.interfaces.ECPublicKey;
+
+import org.testng.Assert;
+import org.testng.annotations.Test;
+
+import com.nimbusds.jose.jwk.Curve;
+import com.nimbusds.jose.jwk.ECKey;
+
+/**
+ * Tests for {@link CredentialIssuerLookupFunction}
+ */
+public class CredentialIssuerLookupFunctionTest {
+
+    private static final String X = "qQ9ESeIrQ36JijWM-8xdcjXwY46RW3p9YDtP0MVaLnE";
+
+    private static final String Y = "5yVZRqwp1tde_CnKC662wW-XFQhEOrGAPi0LTG4OpI8";
+
+    private static final String EXPECTED = "did:jwk:eyJrdHkiOiJFQyIsImNydiI6IlAtMjU2IiwieCI6InFROUVTZUlyUTM2SmlqV00tO"
+            + "HhkY2pYd1k0NlJXM3A5WUR0UDBNVmFMbkUiLCJ5IjoiNXlWWlJxd3AxdGRlX0NuS0M2NjJ3Vy1YRlFoRU9yR0FQaTBMVEc0T3BJOCJ9";
+
+    private ECPublicKey publicKey() throws Exception {
+        return new ECKey.Builder(Curve.P_256, new com.nimbusds.jose.util.Base64URL(X),
+                new com.nimbusds.jose.util.Base64URL(Y)).build().toECPublicKey();
+    }
+
+    @Test
+    public void testDerivesIdentifierOfKeyInService() throws Exception {
+        Assert.assertEquals(new CredentialIssuerLookupFunction().toDidJwk(publicKey()), EXPECTED);
+    }
+
+    @Test
+    public void testEncodesOnlyKeyMembers() throws Exception {
+        final ECKey annotated = new ECKey.Builder(Curve.P_256, new com.nimbusds.jose.util.Base64URL(X),
+                new com.nimbusds.jose.util.Base64URL(Y)).keyUse(com.nimbusds.jose.jwk.KeyUse.SIGNATURE)
+                .algorithm(com.nimbusds.jose.JWSAlgorithm.ES256).keyID("some-kid").build();
+
+        Assert.assertEquals(new CredentialIssuerLookupFunction().toDidJwk(annotated.toECPublicKey()), EXPECTED);
+    }
+
+    @Test
+    public void testNullKey() {
+        Assert.assertNull(new CredentialIssuerLookupFunction().toDidJwk(null));
+    }
+
+    @Test
+    public void testNullProfileRequestContext() {
+        Assert.assertNull(new CredentialIssuerLookupFunction().apply(null));
+    }
+
+}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/oauth/statuslist/StatusListCodecTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/oauth/statuslist/StatusListCodecTest.java
new file mode 100644
index 0000000..1fb44a0
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/oauth/statuslist/StatusListCodecTest.java
@@ -0,0 +1,162 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.oauth.statuslist;
+
+import java.io.ByteArrayOutputStream;
+import java.util.zip.Inflater;
+
+import com.nimbusds.jose.util.Base64URL;
+
+import org.testng.Assert;
+import org.testng.annotations.Test;
+
+/**
+ * Tests for {@link StatusListCodec}
+ */
+public class StatusListCodecTest {
+
+    @Test
+    public void testSizes() {
+        Assert.assertEquals(StatusListCodec.empty().length, StatusListParameters.BYTE_LENGTH);
+        Assert.assertEquals(StatusListCodec.emptyChunk().length, StatusListParameters.CHUNK_BYTE_LENGTH);
+        Assert.assertEquals(StatusListParameters.CHUNK_BYTE_LENGTH * StatusListParameters.CHUNKS,
+                StatusListParameters.BYTE_LENGTH);
+    }
+
+    @Test
+    public void testEmptyListIsAllValid() {
+        final byte[] list = StatusListCodec.empty();
+        for (long index = 0; index < 64; index++) {
+            Assert.assertTrue(StatusListCodec.isValid(list, index));
+        }
+        Assert.assertTrue(StatusListCodec.isValid(list, StatusListParameters.CAPACITY - 1));
+    }
+
+    @Test
+    public void testBitPositionOfSlot() {
+        byte[] list = StatusListCodec.empty();
+        StatusListCodec.setStatus(list, 0, false);
+        Assert.assertEquals(list[0], (byte) 0x01);
+
+        list = StatusListCodec.empty();
+        StatusListCodec.setStatus(list, 1, false);
+        Assert.assertEquals(list[0], (byte) 0x02);
+
+        list = StatusListCodec.empty();
+        StatusListCodec.setStatus(list, 7, false);
+        Assert.assertEquals(list[0], (byte) 0x80);
+
+        list = StatusListCodec.empty();
+        StatusListCodec.setStatus(list, 8, false);
+        Assert.assertEquals(list[0], (byte) 0x00);
+        Assert.assertEquals(list[1], (byte) 0x01);
+    }
+
+    @Test
+    public void testRevokeAndRestore() {
+        final byte[] list = StatusListCodec.empty();
+
+        StatusListCodec.setStatus(list, 14314, false);
+        Assert.assertFalse(StatusListCodec.isValid(list, 14314));
+        Assert.assertTrue(StatusListCodec.isValid(list, 14313));
+        Assert.assertTrue(StatusListCodec.isValid(list, 14315));
+
+        StatusListCodec.setStatus(list, 14314, true);
+        Assert.assertTrue(StatusListCodec.isValid(list, 14314));
+    }
+
+    @Test
+    public void testSlotAddressedWithinThePieceGiven() {
+        final byte[] chunk = StatusListCodec.emptyChunk();
+        final long last = StatusListParameters.CHUNK_CAPACITY - 1;
+
+        StatusListCodec.setStatus(chunk, last, false);
+        Assert.assertFalse(StatusListCodec.isValid(chunk, last));
+        Assert.assertEquals(chunk[StatusListParameters.CHUNK_BYTE_LENGTH - 1], (byte) 0x80);
+
+        try {
+            StatusListCodec.setStatus(chunk, StatusListParameters.CHUNK_CAPACITY, false);
+            Assert.fail("A slot beyond the piece should have been rejected");
+        } catch (IndexOutOfBoundsException e) {
+        }
+    }
+
+    @Test
+    public void testSlotOutsideList() {
+        final byte[] list = StatusListCodec.empty();
+        try {
+            StatusListCodec.setStatus(list, StatusListParameters.CAPACITY, false);
+            Assert.fail("A slot beyond the list should have been rejected");
+        } catch (IndexOutOfBoundsException e) {
+        }
+        try {
+            StatusListCodec.isValid(list, -1);
+            Assert.fail("A negative slot should have been rejected");
+        } catch (IndexOutOfBoundsException e) {
+        }
+    }
+
+    @Test
+    public void testEncodedListIsZlibDeflate() throws Exception {
+        final byte[] list = StatusListCodec.empty();
+        StatusListCodec.setStatus(list, 14314, false);
+
+        final byte[] compressed = new Base64URL(StatusListCodec.encode(list)).decode();
+
+        Assert.assertEquals(compressed[0] & 0x0f, 8);
+        Assert.assertEquals(((compressed[0] & 0xff) * 256 + (compressed[1] & 0xff)) % 31, 0);
+
+        final Inflater inflater = new Inflater();
+        try {
+            inflater.setInput(compressed);
+            final ByteArrayOutputStream inflated = new ByteArrayOutputStream();
+            final byte[] buffer = new byte[8192];
+            while (!inflater.finished()) {
+                inflated.write(buffer, 0, inflater.inflate(buffer));
+            }
+            Assert.assertEquals(inflated.toByteArray(), list);
+        } finally {
+            inflater.end();
+        }
+    }
+
+    @Test
+    public void testEmptyListCompressesSmall() {
+        Assert.assertTrue(StatusListCodec.encode(StatusListCodec.empty()).length() < 500);
+    }
+
+    @Test
+    public void testChunkRoundTrip() throws Exception {
+        final byte[] chunk = StatusListCodec.emptyChunk();
+        StatusListCodec.setStatus(chunk, 1234, false);
+
+        final String encoded = StatusListCodec.encodeChunk(chunk);
+        Assert.assertEquals(StatusListCodec.decodeChunk(encoded), chunk);
+
+        Assert.assertEquals(encoded.length(), StatusListCodec.encodeChunk(StatusListCodec.emptyChunk()).length());
+    }
+
+    @Test
+    public void testDecodeRejectsWrongLength() {
+        try {
+            StatusListCodec.decodeChunk(StatusListCodec.encodeChunk(new byte[8]));
+            Assert.fail("A piece of the wrong length should have been rejected");
+        } catch (Exception e) {
+        }
+    }
+
+}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/AllocateIndexTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/AllocateIndexTest.java
index 4d20437..5a6afd5 100644
--- a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/AllocateIndexTest.java
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/oauth/statuslist/profile/impl/AllocateIndexTest.java
@@ -17,8 +17,11 @@
 package org.geant.shibboleth.plugin.oauth.statuslist.profile.impl;
 
 import java.io.IOException;
+import java.time.Instant;
+import java.util.HashSet;
+import java.util.List;
 
-import org.geant.shibboleth.plugin.oauth.statuslist.StatusListIndex;
+import org.geant.shibboleth.plugin.oauth.statuslist.StatusListBucket;
 import org.geant.shibboleth.plugin.oauth.statuslist.context.StatusListContext;
 import org.geant.shibboleth.plugin.oauth.statuslist.storage.StatusListIndexAllocator;
 import org.opensaml.messaging.context.MessageContext;
@@ -44,7 +47,15 @@ import net.shibboleth.shared.primitive.NonnullSupplier;
  */
 public class AllocateIndexTest {
 
-    private static final int TEST_MAX_INDEX = 1000;
+    private static final String TEST_KEY = "test-permutation-key";
+
+    /** Expiration falling in the 2027-02 bucket. */
+    private static final Instant EXPIRATION = Instant.parse("2027-02-18T12:00:00Z");
+
+    /** Expiration falling in the 2027-03 bucket. */
+    private static final Instant LATER_EXPIRATION = Instant.parse("2027-03-01T00:00:00Z");
+
+    private static final String EXPECTED_URI = "https://issuer.example.org/idp/profile/oauth/status-list/list?id=2027-02";
 
     protected RequestContext requestCtx;
 
@@ -65,8 +76,9 @@ public class AllocateIndexTest {
         storageService.initialize();
 
         allocator = new StatusListIndexAllocator();
+        allocator.setId("test");
         allocator.setStorage(storageService);
-        allocator.setMaxIndex(TEST_MAX_INDEX);
+        allocator.setPermutationKey(TEST_KEY);
         allocator.initialize();
 
         requestCtx = new RequestContextBuilder().buildRequestContext();
@@ -78,14 +90,7 @@ public class AllocateIndexTest {
         httpRequest.setServerName("issuer.example.org");
         httpRequest.setServerPort(443);
 
-        action = new AllocateIndex();
-        action.setStatusListAllocator(allocator);
-        action.setHttpServletRequestSupplier(new NonnullSupplier<>() {
-            public HttpServletRequest get() {
-                return httpRequest;
-            }
-        });
-        action.initialize();
+        action = newAction(allocator, EXPIRATION, 1, null);
     }
 
     @AfterMethod
@@ -100,6 +105,28 @@ public class AllocateIndexTest {
         }
     }
 
+    private AllocateIndex newAction(final StatusListIndexAllocator statusListAllocator, final Instant expiration,
+            final Integer count, final String pathPrefix) throws ComponentInitializationException {
+        AllocateIndex a = new AllocateIndex();
+        a.setStatusListAllocator(statusListAllocator);
+        a.setExpirationLookupStrategy(prc -> expiration);
+        a.setIndexCountLookupStrategy(prc -> count);
+        if (pathPrefix != null) {
+            a.setPathPrefix(pathPrefix);
+        }
+        a.setHttpServletRequestSupplier(new NonnullSupplier<>() {
+            public HttpServletRequest get() {
+                return httpRequest;
+            }
+        });
+        a.initialize();
+        return a;
+    }
+
+    private StatusListContext outboundStatusListContext() {
+        return profileRequestCtx.getOutboundMessageContext().getSubcontext(StatusListContext.class);
+    }
+
     @Test
     public void testInitFailsWithoutAllocator() {
         AllocateIndex a = new AllocateIndex();
@@ -110,6 +137,30 @@ public class AllocateIndexTest {
         }
     }
 
+    @Test
+    public void testInitFailsWithoutExpirationStrategy() {
+        AllocateIndex a = new AllocateIndex();
+        a.setStatusListAllocator(allocator);
+        a.setIndexCountLookupStrategy(prc -> 1);
+        try {
+            a.initialize();
+            Assert.fail("Missing expiration lookup strategy should have caused initialization failure");
+        } catch (ComponentInitializationException e) {
+        }
+    }
+
+    @Test
+    public void testInitFailsWithoutCountStrategy() {
+        AllocateIndex a = new AllocateIndex();
+        a.setStatusListAllocator(allocator);
+        a.setExpirationLookupStrategy(prc -> EXPIRATION);
+        try {
+            a.initialize();
+            Assert.fail("Missing index count lookup strategy should have caused initialization failure");
+        } catch (ComponentInitializationException e) {
+        }
+    }
+
     @Test
     public void testInitFailsWithNullAllocator() {
         AllocateIndex a = new AllocateIndex();
@@ -123,67 +174,68 @@ public class AllocateIndexTest {
     @Test
     public void testSuccess() {
         ActionTestingSupport.assertProceedEvent(action.execute(requestCtx));
-        StatusListContext ctx = profileRequestCtx.getOutboundMessageContext().getSubcontext(StatusListContext.class);
+        StatusListContext ctx = outboundStatusListContext();
         Assert.assertNotNull(ctx);
-        Assert.assertEquals(ctx.getIndex(), 0);
-        Assert.assertEquals(ctx.getStatusListUrlPath(), "https://issuer.example.org/idp/profile/statusList/0");
+        Assert.assertEquals(ctx.getStatusListUri(), EXPECTED_URI);
+        Assert.assertEquals(ctx.getIndices().size(), 1);
+        Assert.assertNotNull(ctx.getIndex(0));
+        Assert.assertNull(ctx.getIndex(1));
     }
 
     @Test
-    public void testSuccessSequentialAllocations() {
-        ActionTestingSupport.assertProceedEvent(action.execute(requestCtx));
-        StatusListContext firstCtx = profileRequestCtx.getOutboundMessageContext()
-                .getSubcontext(StatusListContext.class);
-        Assert.assertEquals(firstCtx.getIndex(), 0);
-        Assert.assertEquals(firstCtx.getStatusListUrlPath(), "https://issuer.example.org/idp/profile/statusList/0");
+    public void testBatchAllocatesOneSlotPerCredential() throws ComponentInitializationException {
+        AllocateIndex a = newAction(allocator, EXPIRATION, 3, null);
+        ActionTestingSupport.assertProceedEvent(a.execute(requestCtx));
+        StatusListContext ctx = outboundStatusListContext();
+        Assert.assertEquals(ctx.getIndices().size(), 3);
+        Assert.assertEquals(new HashSet<>(ctx.getIndices()).size(), 3, "Batch must not reuse a slot");
+        // One expiration means one list, so a batch shares its uri.
+        Assert.assertEquals(ctx.getStatusListUri(), EXPECTED_URI);
+    }
 
-        profileRequestCtx.setOutboundMessageContext(new MessageContext());
-        ActionTestingSupport.assertProceedEvent(action.execute(requestCtx));
-        StatusListContext secondCtx = profileRequestCtx.getOutboundMessageContext()
-                .getSubcontext(StatusListContext.class);
-        Assert.assertEquals(secondCtx.getIndex(), 1);
-        Assert.assertEquals(secondCtx.getStatusListUrlPath(), "https://issuer.example.org/idp/profile/statusList/0");
+    @Test
+    public void testBucketChosenFromExpiration() throws ComponentInitializationException {
+        AllocateIndex a = newAction(allocator, LATER_EXPIRATION, 1, null);
+        ActionTestingSupport.assertProceedEvent(a.execute(requestCtx));
+        Assert.assertEquals(outboundStatusListContext().getStatusListUri(),
+                "https://issuer.example.org/idp/profile/oauth/status-list/list?id=2027-03");
     }
 
     @Test
-    public void testRolloverUrlPath() throws IOException {
-        for (int i = 0; i < TEST_MAX_INDEX; i++) {
-            allocator.allocate();
-        }
+    public void testSequentialAllocationsDoNotRepeat() {
         ActionTestingSupport.assertProceedEvent(action.execute(requestCtx));
-        StatusListContext ctx = profileRequestCtx.getOutboundMessageContext().getSubcontext(StatusListContext.class);
-        Assert.assertEquals(ctx.getIndex(), 0);
-        Assert.assertEquals(ctx.getStatusListUrlPath(), "https://issuer.example.org/idp/profile/statusList/1");
+        Long first = outboundStatusListContext().getIndex(0);
+
+        profileRequestCtx.setOutboundMessageContext(new MessageContext());
+        ActionTestingSupport.assertProceedEvent(action.execute(requestCtx));
+        StatusListContext secondCtx = outboundStatusListContext();
+
+        Assert.assertNotEquals(secondCtx.getIndex(0), first);
+        Assert.assertEquals(secondCtx.getStatusListUri(), EXPECTED_URI);
     }
 
     @Test
     public void testCustomPathPrefix() throws ComponentInitializationException {
-        AllocateIndex a = new AllocateIndex();
-        a.setStatusListAllocator(allocator);
-        a.setPathPrefix("/custom/status");
-        a.setHttpServletRequestSupplier(new NonnullSupplier<>() {
-            public HttpServletRequest get() {
-                return httpRequest;
-            }
-        });
-        a.initialize();
+        AllocateIndex a = newAction(allocator, EXPIRATION, 1, "/custom/status");
         ActionTestingSupport.assertProceedEvent(a.execute(requestCtx));
-        StatusListContext ctx = profileRequestCtx.getOutboundMessageContext().getSubcontext(StatusListContext.class);
-        Assert.assertEquals(ctx.getStatusListUrlPath(), "https://issuer.example.org/custom/status/0");
+        Assert.assertEquals(outboundStatusListContext().getStatusListUri(),
+                "https://issuer.example.org/custom/status?id=2027-02");
     }
 
     @Test
-    public void testNonDefaultPortIncludedInUrl() throws ComponentInitializationException {
+    public void testNonDefaultPortIncludedInUrl() {
         httpRequest.setServerPort(8443);
         ActionTestingSupport.assertProceedEvent(action.execute(requestCtx));
-        StatusListContext ctx = profileRequestCtx.getOutboundMessageContext().getSubcontext(StatusListContext.class);
-        Assert.assertEquals(ctx.getStatusListUrlPath(), "https://issuer.example.org:8443/idp/profile/statusList/0");
+        Assert.assertEquals(outboundStatusListContext().getStatusListUri(),
+                "https://issuer.example.org:8443/idp/profile/oauth/status-list/list?id=2027-02");
     }
 
     @Test
     public void testNoHttpServletRequest() throws ComponentInitializationException {
         AllocateIndex a = new AllocateIndex();
         a.setStatusListAllocator(allocator);
+        a.setExpirationLookupStrategy(prc -> EXPIRATION);
+        a.setIndexCountLookupStrategy(prc -> 1);
         a.initialize();
         ActionTestingSupport.assertEvent(a.execute(requestCtx), EventIds.INVALID_PROFILE_CTX);
     }
@@ -194,27 +246,38 @@ public class AllocateIndexTest {
         ActionTestingSupport.assertEvent(action.execute(requestCtx), EventIds.INVALID_PROFILE_CTX);
     }
 
+    @Test
+    public void testNoExpirationAvailable() throws ComponentInitializationException {
+        AllocateIndex a = newAction(allocator, null, 1, null);
+        ActionTestingSupport.assertEvent(a.execute(requestCtx), EventIds.INVALID_PROFILE_CTX);
+    }
+
+    @Test
+    public void testNoCountAvailable() throws ComponentInitializationException {
+        AllocateIndex a = newAction(allocator, EXPIRATION, null, null);
+        ActionTestingSupport.assertEvent(a.execute(requestCtx), EventIds.INVALID_PROFILE_CTX);
+    }
+
+    @Test
+    public void testNonPositiveCount() throws ComponentInitializationException {
+        AllocateIndex a = newAction(allocator, EXPIRATION, 0, null);
+        ActionTestingSupport.assertEvent(a.execute(requestCtx), EventIds.INVALID_PROFILE_CTX);
+    }
+
     @Test
     public void testAllocatorIoError() throws ComponentInitializationException {
         StatusListIndexAllocator failing = new StatusListIndexAllocator() {
             @Override
-            public StatusListIndex allocate() throws IOException {
+            public List<Long> allocate(final StatusListBucket bucket, final int count) throws IOException {
                 throw new IOException("simulated storage failure");
             }
         };
+        failing.setId("failing");
         failing.setStorage(storageService);
-        failing.setMaxIndex(TEST_MAX_INDEX);
+        failing.setPermutationKey(TEST_KEY);
         failing.initialize();
 
-        AllocateIndex a = new AllocateIndex();
-        a.setStatusListAllocator(failing);
-        a.setHttpServletRequestSupplier(new NonnullSupplier<>() {
-            public HttpServletRequest get() {
-                return httpRequest;
-            }
-        });
-        a.initialize();
-
+        AllocateIndex a = newAction(failing, EXPIRATION, 1, null);
         ActionTestingSupport.assertEvent(a.execute(requestCtx), EventIds.IO_ERROR);
     }
 
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/FeistelIndexPermutationTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/FeistelIndexPermutationTest.java
new file mode 100644
index 0000000..acefc37
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/FeistelIndexPermutationTest.java
@@ -0,0 +1,162 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.oauth.statuslist.storage;
+
+import java.nio.charset.StandardCharsets;
+import java.util.HashSet;
+import java.util.Set;
+
+import org.geant.shibboleth.plugin.oauth.statuslist.StatusListParameters;
+
+import org.testng.Assert;
+import org.testng.annotations.Test;
+
+/**
+ * Tests for {@link FeistelIndexPermutation}
+ */
+public class FeistelIndexPermutationTest {
+
+    /** Narrow width, so the whole slot space can be walked. */
+    private static final int TEST_BITS = 8;
+
+    private static final int TEST_SPACE = 256;
+
+    private static final byte[] KEY = "test-permutation-key".getBytes(StandardCharsets.UTF_8);
+
+    private static final String TWEAK = "2027-02";
+
+    @Test
+    public void testConstructorConstraints() {
+        try {
+            new FeistelIndexPermutation(7, KEY);
+            Assert.fail("Odd width should have caused constraint violation");
+        } catch (Exception e) {
+        }
+        try {
+            new FeistelIndexPermutation(6, KEY);
+            Assert.fail("Width below 8 should have caused constraint violation");
+        } catch (Exception e) {
+        }
+        try {
+            new FeistelIndexPermutation(34, KEY);
+            Assert.fail("Width above 32 should have caused constraint violation");
+        } catch (Exception e) {
+        }
+        try {
+            new FeistelIndexPermutation(TEST_BITS, new byte[0]);
+            Assert.fail("Empty key should have caused constraint violation");
+        } catch (Exception e) {
+        }
+    }
+
+    @Test
+    public void testIsBijectionOverWholeSpace() {
+        FeistelIndexPermutation permutation = new FeistelIndexPermutation(TEST_BITS, KEY);
+
+        Set<Long> indices = new HashSet<>();
+        for (long counter = 0; counter < TEST_SPACE; counter++) {
+            long index = permutation.permute(counter, TWEAK);
+            Assert.assertTrue(index >= 0 && index < TEST_SPACE, "Index must stay inside the slot space");
+            indices.add(index);
+        }
+
+        Assert.assertEquals(indices.size(), TEST_SPACE, "Permutation must visit every slot exactly once");
+    }
+
+    @Test
+    public void testIsBijectionOverTheSpaceInUse() {
+        final FeistelIndexPermutation permutation = new FeistelIndexPermutation(StatusListParameters.INDEX_BITS, KEY);
+        final long capacity = StatusListParameters.CAPACITY;
+
+        // One bit per slot, so a million slots is 128 kilobytes and no boxing.
+        final long[] seen = new long[(int) (capacity / 64)];
+
+        for (long counter = 0; counter < capacity; counter++) {
+            final long index = permutation.permute(counter, TWEAK);
+
+            if (index < 0 || index >= capacity) {
+                Assert.fail("Counter " + counter + " gave index " + index + " outside the list");
+            }
+
+            final int word = (int) (index >>> 6);
+            final long bit = 1L << (index & 63);
+            if ((seen[word] & bit) != 0) {
+                Assert.fail("Slot " + index + " was hit twice, at counter " + counter);
+            }
+            seen[word] |= bit;
+        }
+
+        for (int word = 0; word < seen.length; word++) {
+            if (seen[word] != -1L) {
+                Assert.fail("Slots of word " + word + " were not all hit, got " + Long.toHexString(seen[word]));
+            }
+        }
+    }
+
+    @Test
+    public void testDeterministic() {
+        FeistelIndexPermutation first = new FeistelIndexPermutation(TEST_BITS, KEY);
+        FeistelIndexPermutation second = new FeistelIndexPermutation(TEST_BITS, KEY);
+
+        for (long counter = 0; counter < TEST_SPACE; counter++) {
+            Assert.assertEquals(second.permute(counter, TWEAK), first.permute(counter, TWEAK),
+                    "Same key must survive a restart with the same mapping");
+        }
+    }
+
+    @Test
+    public void testTweakSeparatesBuckets() {
+        FeistelIndexPermutation permutation = new FeistelIndexPermutation(TEST_BITS, KEY);
+
+        int shared = 0;
+        for (long counter = 0; counter < TEST_SPACE; counter++) {
+            if (permutation.permute(counter, TWEAK) == permutation.permute(counter, "2027-03")) {
+                shared++;
+            }
+        }
+        Assert.assertTrue(shared < TEST_SPACE / 4, "Buckets must not permute alike, shared placements: " + shared);
+    }
+
+    @Test
+    public void testKeySeparatesPermutations() {
+        FeistelIndexPermutation permutation = new FeistelIndexPermutation(TEST_BITS, KEY);
+        FeistelIndexPermutation other = new FeistelIndexPermutation(TEST_BITS,
+                "another-permutation-key".getBytes(StandardCharsets.UTF_8));
+
+        int shared = 0;
+        for (long counter = 0; counter < TEST_SPACE; counter++) {
+            if (permutation.permute(counter, TWEAK) == other.permute(counter, TWEAK)) {
+                shared++;
+            }
+        }
+        Assert.assertTrue(shared < TEST_SPACE / 4, "Keys must not permute alike, shared placements: " + shared);
+    }
+
+    @Test
+    public void testConsecutiveCountersAreNotConsecutiveIndices() {
+        FeistelIndexPermutation permutation = new FeistelIndexPermutation(TEST_BITS, KEY);
+
+        int adjacent = 0;
+        for (long counter = 0; counter < TEST_SPACE - 1; counter++) {
+            if (permutation.permute(counter + 1, TWEAK) - permutation.permute(counter, TWEAK) == 1) {
+                adjacent++;
+            }
+        }
+        Assert.assertTrue(adjacent < TEST_SPACE / 8, "Indices track allocation order, adjacent pairs: " + adjacent);
+    }
+
+}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListAssignmentStoreTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListAssignmentStoreTest.java
new file mode 100644
index 0000000..2f504fa
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListAssignmentStoreTest.java
@@ -0,0 +1,233 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.oauth.statuslist.storage;
+
+import java.io.IOException;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+
+import org.opensaml.storage.StorageRecord;
+import org.opensaml.storage.impl.MemoryStorageService;
+import org.testng.Assert;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+/**
+ * Tests for {@link StatusListAssignmentStore}
+ */
+public class StatusListAssignmentStoreTest {
+
+    private MemoryStorageService storageService;
+
+    private StatusListAssignmentStore store;
+
+    @BeforeMethod
+    protected void setUp() throws Exception {
+        storageService = new MemoryStorageService();
+        storageService.setId("test");
+        storageService.initialize();
+
+        store = newStore();
+    }
+
+    @AfterMethod
+    protected void tearDown() {
+        store.destroy();
+        store = null;
+        storageService.destroy();
+        storageService = null;
+    }
+
+    private StatusListAssignmentStore newStore() throws Exception {
+        final StatusListAssignmentStore created = new StatusListAssignmentStore();
+        created.setId("test");
+        created.setStorage(storageService);
+        created.initialize();
+        return created;
+    }
+
+    private StatusListAssignment record(final String id, final List<Long> indices) {
+        final StatusListAssignment assignment = new StatusListAssignment();
+        assignment.setId(id);
+        assignment.setIndices(indices);
+        assignment.setExpires(1802769445L);
+        assignment.setDetails(Map.of("format", "vc+sd-jwt", "claims", Map.of("given_name", "Alice")));
+        return assignment;
+    }
+
+    @Test
+    public void testNothingStoredReadsEmpty() throws IOException {
+        final StatusListAssignments page = store.read(0, 100);
+
+        Assert.assertTrue(page.getRecords().isEmpty());
+        Assert.assertEquals(page.getHighWater(), 0);
+        Assert.assertEquals(page.getNext(), 0, "With nothing written there is nowhere to continue from");
+    }
+
+    @Test
+    public void testStoreAndRead() throws IOException {
+        store.store(record("2027-02", List.of(14314L)));
+
+        final StatusListAssignments page = store.read(0, 100);
+        Assert.assertEquals(page.getRecords().size(), 1);
+        Assert.assertEquals(page.getHighWater(), 1);
+        Assert.assertEquals(page.getNext(), 1);
+
+        final StatusListAssignment read = page.getRecords().get(0);
+        Assert.assertEquals(read.getSeq(), 0, "A record carries the position it was stored at");
+        Assert.assertEquals(read.getId(), "2027-02");
+        Assert.assertEquals(read.getIndices(), List.of(14314L));
+        Assert.assertEquals(read.getExpires(), 1802769445L);
+        Assert.assertEquals(((Map<?, ?>) read.getDetails().get("claims")).get("given_name"), "Alice");
+    }
+
+    @Test
+    public void testPositionsAreTakenInOrder() throws IOException {
+        store.store(record("2027-02", List.of(1L)));
+        store.store(record("2027-02", List.of(2L)));
+        store.store(record("2027-03", List.of(3L)));
+
+        final StatusListAssignments page = store.read(0, 100);
+        Assert.assertEquals(page.getRecords().size(), 3);
+        Assert.assertEquals(page.getRecords().get(0).getSeq(), 0);
+        Assert.assertEquals(page.getRecords().get(1).getSeq(), 1);
+        Assert.assertEquals(page.getRecords().get(2).getSeq(), 2);
+        Assert.assertEquals(page.getHighWater(), 3);
+    }
+
+    @Test
+    public void testPagingContinuesWhereItStopped() throws IOException {
+        for (int i = 0; i < 5; i++) {
+            store.store(record("2027-02", List.of((long) i)));
+        }
+
+        final StatusListAssignments first = store.read(0, 2);
+        Assert.assertEquals(first.getRecords().size(), 2);
+        Assert.assertEquals(first.getNext(), 2);
+        Assert.assertEquals(first.getHighWater(), 5);
+
+        final StatusListAssignments second = store.read(first.getNext(), 2);
+        Assert.assertEquals(second.getRecords().size(), 2);
+        Assert.assertEquals(second.getRecords().get(0).getSeq(), 2);
+        Assert.assertEquals(second.getNext(), 4);
+
+        final StatusListAssignments third = store.read(second.getNext(), 2);
+        Assert.assertEquals(third.getRecords().size(), 1);
+        Assert.assertEquals(third.getNext(), third.getHighWater(), "Reaching the high water means caught up");
+    }
+
+    @Test
+    public void testGapsAreSkipped() throws IOException {
+        store.store(record("2027-02", List.of(1L)));
+        store.store(record("2027-02", List.of(2L)));
+        store.store(record("2027-02", List.of(3L)));
+
+        storageService.delete(StatusListAssignmentStore.class.getName(), "1");
+
+        final StatusListAssignments page = store.read(0, 100);
+        Assert.assertEquals(page.getRecords().size(), 2);
+        Assert.assertEquals(page.getRecords().get(0).getSeq(), 0);
+        Assert.assertEquals(page.getRecords().get(1).getSeq(), 2);
+        Assert.assertEquals(page.getNext(), 3);
+    }
+
+    @Test
+    public void testReadingBeyondWhatWasWritten() throws IOException {
+        store.store(record("2027-02", List.of(1L)));
+
+        final StatusListAssignments page = store.read(50, 100);
+        Assert.assertTrue(page.getRecords().isEmpty());
+        Assert.assertEquals(page.getNext(), 50, "A collector asking past the end stays where it was");
+        Assert.assertEquals(page.getHighWater(), 1);
+    }
+
+    @Test
+    public void testSequenceSurvivesRestart() throws Exception {
+        store.store(record("2027-02", List.of(1L)));
+        store.destroy();
+
+        store = newStore();
+        store.store(record("2027-02", List.of(2L)));
+
+        final StatusListAssignments page = store.read(0, 100);
+        Assert.assertEquals(page.getRecords().size(), 2);
+        Assert.assertEquals(page.getRecords().get(1).getSeq(), 1);
+    }
+
+    @Test
+    public void testRecordExpiresAfterRetention() throws Exception {
+        final StatusListAssignmentStore shortLived = new StatusListAssignmentStore();
+        shortLived.setId("shortLived");
+        shortLived.setStorage(storageService);
+        shortLived.setRetention(Duration.ofDays(1));
+        shortLived.initialize();
+
+        try {
+            final Instant before = Instant.now();
+            shortLived.store(record("2027-02", List.of(1L)));
+            final Instant after = Instant.now();
+
+            final StorageRecord<String> stored = storageService.read(StatusListAssignmentStore.class.getName(), "0");
+            Assert.assertNotNull(stored.getExpiration(), "A record kept for ever would hold its claims for ever");
+            Assert.assertTrue(
+                    stored.getExpiration() >= before.plus(Duration.ofDays(1)).toEpochMilli()
+                            && stored.getExpiration() <= after.plus(Duration.ofDays(1)).toEpochMilli(),
+                    "The record does not expire after the retention configured");
+        } finally {
+            shortLived.destroy();
+        }
+    }
+
+    @Test
+    public void testSequenceDoesNotExpire() throws IOException {
+        store.store(record("2027-02", List.of(1L)));
+
+        final StorageRecord<String> sequence = storageService.read(StatusListAssignmentStore.class.getName(),
+                "sequence");
+        Assert.assertNotNull(sequence);
+        Assert.assertNull(sequence.getExpiration(),
+                "Losing the sequence would restart it and write records over one another");
+    }
+
+    @Test
+    public void testBounds() throws IOException {
+        try {
+            store.read(-1, 10);
+            Assert.fail("A negative position should have been rejected");
+        } catch (Exception e) {
+        }
+        try {
+            store.read(0, 0);
+            Assert.fail("A limit of zero should have been rejected");
+        } catch (Exception e) {
+        }
+    }
+
+    @Test
+    public void testRetentionMustBePositive() throws Exception {
+        final StatusListAssignmentStore created = new StatusListAssignmentStore();
+        try {
+            created.setRetention(Duration.ZERO);
+            Assert.fail("A retention of zero should have been rejected");
+        } catch (Exception e) {
+        }
+    }
+
+}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListIndexAllocatorTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListIndexAllocatorTest.java
index d54cb2f..13fa1eb 100644
--- a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListIndexAllocatorTest.java
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListIndexAllocatorTest.java
@@ -17,8 +17,14 @@
 package org.geant.shibboleth.plugin.oauth.statuslist.storage;
 
 import java.io.IOException;
-
-import org.geant.shibboleth.plugin.oauth.statuslist.StatusListIndex;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import org.geant.shibboleth.plugin.oauth.statuslist.StatusListBucket;
+import org.opensaml.storage.StorageRecord;
 import org.opensaml.storage.impl.MemoryStorageService;
 import org.opensaml.storage.impl.client.ClientStorageService;
 import org.testng.Assert;
@@ -35,17 +41,20 @@ public class StatusListIndexAllocatorTest {
 
     private StatusListIndexAllocator allocator;
 
-    private static final int TEST_MAX_INDEX = 1000;
+    private static final String TEST_KEY = "test-permutation-key";
+
+    private static final StatusListBucket BUCKET = new StatusListBucket("2027-02",
+            Instant.parse("2027-03-01T00:00:00Z"));
+
+    private static final StatusListBucket OTHER_BUCKET = new StatusListBucket("2027-03",
+            Instant.parse("2027-04-01T00:00:00Z"));
 
     @BeforeMethod
     protected void setUp() throws Exception {
         storageService = new MemoryStorageService();
         storageService.setId("test");
         storageService.initialize();
-        allocator = new StatusListIndexAllocator();
-        allocator.setStorage(storageService);
-        allocator.setMaxIndex(TEST_MAX_INDEX);
-        allocator.initialize();
+        allocator = newAllocator();
     }
 
     @AfterMethod
@@ -56,6 +65,15 @@ public class StatusListIndexAllocatorTest {
         storageService = null;
     }
 
+    private StatusListIndexAllocator newAllocator() throws Exception {
+        StatusListIndexAllocator newAllocator = new StatusListIndexAllocator();
+        newAllocator.setId("test");
+        newAllocator.setStorage(storageService);
+        newAllocator.setPermutationKey(TEST_KEY);
+        newAllocator.initialize();
+        return newAllocator;
+    }
+
     @Test
     public void testInit() {
         StatusListIndexAllocator listAllocator = new StatusListIndexAllocator();
@@ -70,8 +88,20 @@ public class StatusListIndexAllocatorTest {
         } catch (Exception e) {
         }
         try {
-            listAllocator.setMaxIndex(999);
-            Assert.fail("maxIndex below 1000 should have caused constraint violation");
+            listAllocator.setMaxSlotsPerAllocation(0);
+            Assert.fail("Non-positive maxSlotsPerAllocation should have caused constraint violation");
+        } catch (Exception e) {
+        }
+    }
+
+    @Test
+    public void testInitRequiresPermutationKey() throws Exception {
+        StatusListIndexAllocator listAllocator = new StatusListIndexAllocator();
+        listAllocator.setId("test");
+        listAllocator.setStorage(storageService);
+        try {
+            listAllocator.initialize();
+            Assert.fail("Missing permutation key should have prevented initialization");
         } catch (Exception e) {
         }
     }
@@ -82,55 +112,99 @@ public class StatusListIndexAllocatorTest {
     }
 
     @Test
-    public void testFirstAllocation() throws IOException {
-        StatusListIndex first = allocator.allocate();
-        Assert.assertEquals(first.index(), 0);
-        Assert.assertEquals(first.uriIndex(), 0);
+    public void testSingleAllocation() throws IOException {
+        List<Long> indices = allocator.allocate(BUCKET, 1);
+        Assert.assertEquals(indices.size(), 1);
+        Assert.assertTrue(indices.get(0) >= 0 && indices.get(0) < allocator.getCapacity());
+    }
+
+    @Test
+    public void testBatchAllocationIsDistinctAndNotSequential() throws IOException {
+        List<Long> indices = allocator.allocate(BUCKET, 3);
+        Assert.assertEquals(indices.size(), 3);
+        Assert.assertEquals(new HashSet<>(indices).size(), 3, "Batch must not reuse a slot");
+        for (Long index : indices) {
+            Assert.assertTrue(index >= 0 && index < allocator.getCapacity());
+        }
+        Assert.assertNotEquals(indices, List.of(0L, 1L, 2L), "Indices must not expose allocation order");
+    }
+
+    @Test
+    public void testAllocationsNeverRepeatWithinBucket() throws IOException {
+        final int allocations = 500;
+        Set<Long> seen = new HashSet<>();
+        for (int i = 0; i < allocations; i++) {
+            seen.addAll(allocator.allocate(BUCKET, 1));
+        }
+        Assert.assertEquals(seen.size(), allocations, "No slot may be handed out twice");
+    }
+
+    @Test
+    public void testStateExpiresAfterBucketEnd() throws IOException {
+        allocator.allocate(BUCKET, 1);
+
+        StorageRecord<String> record = storageService.read(StatusListIndexAllocator.class.getName(), BUCKET.id());
+        Assert.assertNotNull(record);
+        Assert.assertEquals(record.getExpiration(), Long.valueOf(BUCKET.end().plus(Duration.ofDays(7)).toEpochMilli()));
+    }
+
+    @Test
+    public void testBucketsAllocateIndependently() throws IOException {
+        Long first = allocator.allocate(BUCKET, 1).get(0);
+        Long other = allocator.allocate(OTHER_BUCKET, 1).get(0);
+        // Both buckets start from counter zero, but permute under their own identifier.
+        Assert.assertNotEquals(first, other, "Buckets must not share a permutation");
+    }
+
+    @Test
+    public void testExhaustedBucket() throws IOException {
+        // Set the counter to capacity instead of allocating a million slots.
+        storageService.create(StatusListIndexAllocator.class.getName(), BUCKET.id(),
+                Long.toString(allocator.getCapacity()), null);
+        try {
+            allocator.allocate(BUCKET, 1);
+            Assert.fail("Exhausted bucket should have failed allocation");
+        } catch (IOException e) {
+        }
     }
 
     @Test
-    public void testMonotonic() throws IOException {
-        StatusListIndex first = allocator.allocate();
-        StatusListIndex second = allocator.allocate();
-        StatusListIndex third = allocator.allocate();
-        Assert.assertEquals(first.index(), 0);
-        Assert.assertEquals(second.index(), 1);
-        Assert.assertEquals(third.index(), 2);
-        Assert.assertEquals(first.uriIndex(), 0);
-        Assert.assertEquals(second.uriIndex(), 0);
-        Assert.assertEquals(third.uriIndex(), 0);
+    public void testBatchStraddlingCapacityIsRejectedWhole() throws IOException {
+        storageService.create(StatusListIndexAllocator.class.getName(), BUCKET.id(),
+                Long.toString(allocator.getCapacity() - 1), null);
+        try {
+            allocator.allocate(BUCKET, 2);
+            Assert.fail("Batch exceeding remaining capacity should have failed allocation");
+        } catch (IOException e) {
+        }
+        // The rejected batch must not have consumed the last slot.
+        Assert.assertEquals(allocator.allocate(BUCKET, 1).size(), 1);
     }
 
     @Test
-    public void testRollover() throws IOException {
-        StatusListIndex last = null;
-        for (int i = 0; i < TEST_MAX_INDEX; i++) {
-            last = allocator.allocate();
+    public void testCountBounds() throws IOException {
+        try {
+            allocator.allocate(BUCKET, 0);
+            Assert.fail("Zero count should have caused constraint violation");
+        } catch (Exception e) {
+        }
+        try {
+            allocator.allocate(BUCKET, allocator.getMaxSlotsPerAllocation() + 1);
+            Assert.fail("Count above maxSlotsPerAllocation should have caused constraint violation");
+        } catch (Exception e) {
         }
-        Assert.assertEquals(last.index(), TEST_MAX_INDEX - 1);
-        Assert.assertEquals(last.uriIndex(), 0);
-        StatusListIndex rolled = allocator.allocate();
-        Assert.assertEquals(rolled.index(), 0);
-        Assert.assertEquals(rolled.uriIndex(), 1);
-        StatusListIndex afterRoll = allocator.allocate();
-        Assert.assertEquals(afterRoll.index(), 1);
-        Assert.assertEquals(afterRoll.uriIndex(), 1);
     }
 
     @Test
     public void testPersistenceAcrossAllocatorRestart() throws Exception {
-        allocator.allocate();
-        allocator.allocate();
+        Set<Long> seen = new HashSet<>(allocator.allocate(BUCKET, 2));
 
         allocator.destroy();
-        allocator = new StatusListIndexAllocator();
-        allocator.setStorage(storageService);
-        allocator.setMaxIndex(TEST_MAX_INDEX);
-        allocator.initialize();
-
-        StatusListIndex next = allocator.allocate();
-        Assert.assertEquals(next.index(), 2);
-        Assert.assertEquals(next.uriIndex(), 0);
+        allocator = newAllocator();
+
+        List<Long> next = allocator.allocate(BUCKET, 1);
+        Assert.assertEquals(next.size(), 1);
+        Assert.assertFalse(seen.contains(next.get(0)), "Restart must not hand out an already allocated slot");
     }
 
 }
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListStoreTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListStoreTest.java
new file mode 100644
index 0000000..927b4bb
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/oauth/statuslist/storage/StatusListStoreTest.java
@@ -0,0 +1,199 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.oauth.statuslist.storage;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.zip.Inflater;
+
+import org.geant.shibboleth.plugin.oauth.statuslist.StatusListBucket;
+import org.geant.shibboleth.plugin.oauth.statuslist.StatusListCodec;
+import org.geant.shibboleth.plugin.oauth.statuslist.StatusListParameters;
+import org.opensaml.storage.StorageRecord;
+import org.opensaml.storage.impl.MemoryStorageService;
+import org.testng.Assert;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import com.nimbusds.jose.util.Base64URL;
+
+/**
+ * Tests for {@link StatusListStore}
+ */
+public class StatusListStoreTest {
+
+    private MemoryStorageService storageService;
+
+    private StatusListStore store;
+
+    private static final StatusListBucket BUCKET = new StatusListBucket("2027-02",
+            Instant.parse("2027-03-01T00:00:00Z"));
+
+    @BeforeMethod
+    protected void setUp() throws Exception {
+        storageService = new MemoryStorageService();
+        storageService.setId("test");
+        storageService.initialize();
+
+        store = new StatusListStore();
+        store.setId("test");
+        store.setStorage(storageService);
+        store.initialize();
+    }
+
+    @AfterMethod
+    protected void tearDown() {
+        store.destroy();
+        store = null;
+        storageService.destroy();
+        storageService = null;
+    }
+
+    private byte[] published(final String encoded) throws Exception {
+        final Inflater inflater = new Inflater();
+        try {
+            inflater.setInput(new Base64URL(encoded).decode());
+            final ByteArrayOutputStream inflated = new ByteArrayOutputStream();
+            final byte[] buffer = new byte[8192];
+            while (!inflater.finished()) {
+                inflated.write(buffer, 0, inflater.inflate(buffer));
+            }
+            return inflated.toByteArray();
+        } finally {
+            inflater.end();
+        }
+    }
+
+    @Test
+    public void testNothingRevokedHoldsNoList() throws IOException {
+        Assert.assertNull(store.read(BUCKET));
+    }
+
+    @Test
+    public void testRevokeShowsInPublishedList() throws Exception {
+        store.revoke(BUCKET, 14314);
+
+        final String encoded = store.read(BUCKET);
+        Assert.assertNotNull(encoded);
+
+        final byte[] list = published(encoded);
+        Assert.assertEquals(list.length, StatusListParameters.BYTE_LENGTH,
+                "A published list is always of the full size, whatever is revoked");
+        Assert.assertFalse(StatusListCodec.isValid(list, 14314));
+        Assert.assertTrue(StatusListCodec.isValid(list, 14313));
+        Assert.assertTrue(StatusListCodec.isValid(list, 14315));
+    }
+
+    @Test
+    public void testRevokeIsIdempotent() throws Exception {
+        store.revoke(BUCKET, 14314);
+        final String first = store.read(BUCKET);
+        store.revoke(BUCKET, 14314);
+
+        Assert.assertEquals(store.read(BUCKET), first, "Revoking twice leaves the list as it was");
+    }
+
+    @Test
+    public void testRevocationsAcrossPiecesAssembleIntoOneList() throws Exception {
+        final long lastOfFirst = StatusListParameters.CHUNK_CAPACITY - 1;
+        final long firstOfSecond = StatusListParameters.CHUNK_CAPACITY;
+        final long lastOfList = StatusListParameters.CAPACITY - 1;
+
+        store.revoke(BUCKET, 0);
+        store.revoke(BUCKET, lastOfFirst);
+        store.revoke(BUCKET, firstOfSecond);
+        store.revoke(BUCKET, lastOfList);
+
+        final byte[] list = published(store.read(BUCKET));
+        Assert.assertFalse(StatusListCodec.isValid(list, 0));
+        Assert.assertFalse(StatusListCodec.isValid(list, lastOfFirst));
+        Assert.assertFalse(StatusListCodec.isValid(list, firstOfSecond));
+        Assert.assertFalse(StatusListCodec.isValid(list, lastOfList));
+        Assert.assertTrue(StatusListCodec.isValid(list, 1));
+        Assert.assertTrue(StatusListCodec.isValid(list, firstOfSecond + 1));
+    }
+
+    @Test
+    public void testOnlyThePieceHoldingTheSlotIsWritten() throws IOException {
+        store.revoke(BUCKET, 0);
+
+        Assert.assertNotNull(storageService.read(StatusListStore.class.getName(), BUCKET.id() + "#0"));
+        Assert.assertNull(storageService.read(StatusListStore.class.getName(), BUCKET.id() + "#1"),
+                "A piece nothing was revoked from should not have been written");
+    }
+
+    @Test
+    public void testStoredPieceIsOfFixedSize() throws IOException {
+        store.revoke(BUCKET, 0);
+        final StorageRecord<String> record = storageService.read(StatusListStore.class.getName(), BUCKET.id() + "#0");
+
+        Assert.assertEquals(record.getValue().length(),
+                StatusListCodec.encodeChunk(StatusListCodec.emptyChunk()).length());
+    }
+
+    @Test
+    public void testStateExpiresAfterBucketEnd() throws IOException {
+        store.revoke(BUCKET, 14314);
+        final StorageRecord<String> record = storageService.read(StatusListStore.class.getName(), BUCKET.id() + "#0");
+
+        Assert.assertEquals(record.getExpiration(),
+                Long.valueOf(BUCKET.end().plus(StatusListParameters.RETENTION).toEpochMilli()));
+    }
+
+    @Test
+    public void testBucketsAreSeparate() throws Exception {
+        final StatusListBucket other = new StatusListBucket("2027-03", Instant.parse("2027-04-01T00:00:00Z"));
+
+        store.revoke(BUCKET, 14314);
+
+        Assert.assertNull(store.read(other), "Revoking in one list must not touch another");
+    }
+
+    @Test
+    public void testSlotOutsideList() {
+        try {
+            store.revoke(BUCKET, StatusListParameters.CAPACITY);
+            Assert.fail("A slot beyond the list should have been rejected");
+        } catch (Exception e) {
+        }
+        try {
+            store.revoke(BUCKET, -1);
+            Assert.fail("A negative slot should have been rejected");
+        } catch (Exception e) {
+        }
+    }
+
+    @Test
+    public void testInitRequiresStorage() throws Exception {
+        final StatusListStore uninitialized = new StatusListStore();
+        uninitialized.setId("test");
+        try {
+            uninitialized.initialize();
+            Assert.fail("Missing storage should have prevented initialization");
+        } catch (Exception e) {
+        }
+    }
+
+    @Test
+    public void testRetentionCoversTheWholeOfABucket() {
+        Assert.assertTrue(StatusListParameters.RETENTION.compareTo(Duration.ZERO) > 0);
+    }
+
+}
diff --git a/openid-vci-impl/src/test/resources/conf/verifiable-credentials.json b/openid-vci-impl/src/test/resources/conf/verifiable-credentials.json
index 64f5740..65a70c9 100644
--- a/openid-vci-impl/src/test/resources/conf/verifiable-credentials.json
+++ b/openid-vci-impl/src/test/resources/conf/verifiable-credentials.json
@@ -71,8 +71,7 @@
         },
         "credential_definition": {
           "@context": ["https://www.w3.org/2018/credentials/v1"],
-          "type": ["VerifiableCredential", "GeantIncubatorDiploma2"],
-          "issuer": "https://issuer.example.org"
+          "type": ["VerifiableCredential", "GeantIncubatorDiploma2"]
         },
         "credential_metadata": {
           "display": [

-- 
To stop receiving notification emails like this one, please contact
the administrator of this repository.


More information about the commits mailing list