mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-18 04:43:37 +00:00
feat(identity): secure login connection secrets
This commit is contained in:
parent
3c577eaea9
commit
a2a86d3bb6
31 changed files with 2213 additions and 6 deletions
|
|
@ -0,0 +1,72 @@
|
|||
-- Encrypted Login Connection Secret versions. Cleartext never enters this schema.
|
||||
|
||||
CREATE TABLE login_connection_secret_version (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
organization_id VARCHAR(64) REFERENCES organization(id),
|
||||
scope_key VARCHAR(64) GENERATED ALWAYS AS (
|
||||
COALESCE(organization_id, '@platform')
|
||||
) STORED,
|
||||
connection_id VARCHAR(64) NOT NULL,
|
||||
purpose VARCHAR(128) NOT NULL,
|
||||
binding_version BIGINT NOT NULL,
|
||||
algorithm VARCHAR(32) NOT NULL,
|
||||
key_id VARCHAR(128) NOT NULL,
|
||||
nonce BYTEA NOT NULL,
|
||||
ciphertext BYTEA NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
valid_until TIMESTAMPTZ,
|
||||
created_by VARCHAR(128) NOT NULL REFERENCES user_account(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
revoked_by VARCHAR(128) REFERENCES user_account(id),
|
||||
revoked_at TIMESTAMPTZ,
|
||||
version BIGINT NOT NULL DEFAULT 0,
|
||||
CONSTRAINT fk_login_connection_secret_scope
|
||||
FOREIGN KEY (connection_id, scope_key)
|
||||
REFERENCES login_connection(id, scope_key),
|
||||
CONSTRAINT uk_login_connection_secret_binding
|
||||
UNIQUE (connection_id, binding_version),
|
||||
CONSTRAINT ck_login_connection_secret_purpose CHECK (
|
||||
purpose ~ '^[a-z][a-z0-9._-]{2,127}$'
|
||||
),
|
||||
CONSTRAINT ck_login_connection_secret_binding_version CHECK (binding_version > 0),
|
||||
CONSTRAINT ck_login_connection_secret_algorithm CHECK (algorithm = 'AES-256-GCM'),
|
||||
CONSTRAINT ck_login_connection_secret_key_id CHECK (
|
||||
key_id ~ '^[A-Za-z0-9][A-Za-z0-9._-]{2,127}$'
|
||||
),
|
||||
CONSTRAINT ck_login_connection_secret_nonce CHECK (octet_length(nonce) = 12),
|
||||
CONSTRAINT ck_login_connection_secret_ciphertext CHECK (octet_length(ciphertext) > 16),
|
||||
CONSTRAINT ck_login_connection_secret_status CHECK (
|
||||
status IN ('CURRENT', 'RETIRING', 'REVOKED')
|
||||
),
|
||||
CONSTRAINT ck_login_connection_secret_lifecycle CHECK (
|
||||
(status = 'CURRENT'
|
||||
AND valid_until IS NULL AND revoked_by IS NULL AND revoked_at IS NULL)
|
||||
OR (status = 'RETIRING'
|
||||
AND valid_until IS NOT NULL AND revoked_by IS NULL AND revoked_at IS NULL)
|
||||
OR (status = 'REVOKED' AND revoked_by IS NOT NULL AND revoked_at IS NOT NULL)
|
||||
),
|
||||
CONSTRAINT ck_login_connection_secret_version CHECK (version >= 0)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX uk_login_connection_secret_current
|
||||
ON login_connection_secret_version (connection_id, purpose)
|
||||
WHERE status = 'CURRENT';
|
||||
|
||||
CREATE UNIQUE INDEX uk_login_connection_secret_retiring
|
||||
ON login_connection_secret_version (connection_id, purpose)
|
||||
WHERE status = 'RETIRING';
|
||||
|
||||
CREATE INDEX idx_login_connection_secret_tenant_connection
|
||||
ON login_connection_secret_version (
|
||||
organization_id, connection_id, purpose, binding_version DESC
|
||||
);
|
||||
|
||||
CREATE INDEX idx_login_connection_secret_retiring_expiry
|
||||
ON login_connection_secret_version (valid_until, connection_id, purpose)
|
||||
WHERE status = 'RETIRING';
|
||||
|
||||
ALTER TABLE login_connection_revision
|
||||
ADD CONSTRAINT fk_login_connection_revision_secret_binding
|
||||
FOREIGN KEY (connection_id, secret_binding_version)
|
||||
REFERENCES login_connection_secret_version(connection_id, binding_version);
|
||||
|
|
@ -0,0 +1,249 @@
|
|||
package com.iflytek.skillhub.db;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.flywaydb.core.api.MigrationVersion;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
|
||||
@Testcontainers
|
||||
class LoginConnectionSecretMigrationTest {
|
||||
|
||||
@Container
|
||||
private static final PostgreSQLContainer<?> POSTGRES =
|
||||
new PostgreSQLContainer<>("postgres:16-alpine");
|
||||
|
||||
@BeforeEach
|
||||
void resetDatabase() {
|
||||
flyway(null).clean();
|
||||
}
|
||||
|
||||
@Test
|
||||
void addsEncryptedVersionedSecretStorageAndBindsRevisionsToExactVersions()
|
||||
throws SQLException {
|
||||
migrateAndSeedConnections();
|
||||
|
||||
Flyway current = flyway(MigrationVersion.fromVersion("66"));
|
||||
current.migrate();
|
||||
|
||||
assertThat(current.info().current().getVersion().getVersion()).isEqualTo("66");
|
||||
assertThat(queryStrings("""
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'login_connection_secret_version'
|
||||
ORDER BY ordinal_position
|
||||
""")).contains(
|
||||
"organization_id",
|
||||
"connection_id",
|
||||
"purpose",
|
||||
"binding_version",
|
||||
"algorithm",
|
||||
"key_id",
|
||||
"nonce",
|
||||
"ciphertext",
|
||||
"status",
|
||||
"valid_until"
|
||||
).doesNotContain("plaintext", "value", "client_secret");
|
||||
|
||||
execute(secretInsert("secret-a", "org-a", "connection-a", 1, "CURRENT"));
|
||||
execute("""
|
||||
INSERT INTO login_connection_revision (
|
||||
id, connection_id, revision, adapter_contract_version,
|
||||
config_schema_version, capabilities, typed_config,
|
||||
secret_binding_version, created_by
|
||||
) VALUES (
|
||||
'revision-a', 'connection-a', 1, '1.0', 1,
|
||||
'["IDENTITY_ASSERTION"]', '{}', 1, 'creator'
|
||||
)
|
||||
""");
|
||||
|
||||
assertThat(queryLong("""
|
||||
SELECT COUNT(*)
|
||||
FROM login_connection_revision
|
||||
WHERE connection_id = 'connection-a' AND secret_binding_version = 1
|
||||
""")).isEqualTo(1);
|
||||
assertThatThrownBy(() -> execute("""
|
||||
INSERT INTO login_connection_revision (
|
||||
id, connection_id, revision, adapter_contract_version,
|
||||
config_schema_version, capabilities, typed_config,
|
||||
secret_binding_version, created_by
|
||||
) VALUES (
|
||||
'revision-missing-secret', 'connection-a', 2, '1.0', 1,
|
||||
'["IDENTITY_ASSERTION"]', '{}', 99, 'creator'
|
||||
)
|
||||
""")).isInstanceOf(SQLException.class);
|
||||
|
||||
execute("""
|
||||
INSERT INTO login_connection (
|
||||
id, public_handle, scope_type, organization_id, system_key,
|
||||
display_name, status, adapter_key
|
||||
) VALUES (
|
||||
'platform-github', 'platform-github-handle', 'PLATFORM', NULL, 'github',
|
||||
'GitHub', 'DRAFT', 'oauth'
|
||||
)
|
||||
""");
|
||||
execute("""
|
||||
INSERT INTO login_connection_secret_version (
|
||||
id, organization_id, connection_id, purpose, binding_version,
|
||||
algorithm, key_id, nonce, ciphertext, status, valid_until, created_by
|
||||
) VALUES (
|
||||
'platform-secret', NULL, 'platform-github', 'login.client-secret', 1,
|
||||
'AES-256-GCM', 'key-2026-09', decode(repeat('01', 12), 'hex'),
|
||||
decode(repeat('02', 32), 'hex'), 'CURRENT', NULL, 'creator'
|
||||
)
|
||||
""");
|
||||
assertThat(queryLong("""
|
||||
SELECT COUNT(*)
|
||||
FROM login_connection_secret_version
|
||||
WHERE connection_id = 'platform-github' AND scope_key = '@platform'
|
||||
""")).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsCrossTenantSecretsAndMultipleCurrentOrRetiringVersions() throws SQLException {
|
||||
migrateAndSeedConnections();
|
||||
flyway(MigrationVersion.fromVersion("66")).migrate();
|
||||
|
||||
assertThatThrownBy(() -> execute(
|
||||
secretInsert("cross-org", "org-b", "connection-a", 1, "CURRENT")
|
||||
)).isInstanceOf(SQLException.class);
|
||||
assertThatThrownBy(() -> execute("""
|
||||
INSERT INTO login_connection_secret_version (
|
||||
id, organization_id, connection_id, purpose, binding_version,
|
||||
algorithm, key_id, nonce, ciphertext, status, valid_until, created_by
|
||||
) VALUES (
|
||||
'cross-platform', NULL, 'connection-a', 'login.client-secret', 1,
|
||||
'AES-256-GCM', 'key-2026-09', decode(repeat('01', 12), 'hex'),
|
||||
decode(repeat('02', 32), 'hex'), 'CURRENT', NULL, 'creator'
|
||||
)
|
||||
""")).isInstanceOf(SQLException.class);
|
||||
|
||||
execute(secretInsert("current-a", "org-a", "connection-a", 1, "CURRENT"));
|
||||
assertThatThrownBy(() -> execute(
|
||||
secretInsert("current-b", "org-a", "connection-a", 2, "CURRENT")
|
||||
)).isInstanceOf(SQLException.class);
|
||||
|
||||
execute("""
|
||||
UPDATE login_connection_secret_version
|
||||
SET status = 'RETIRING', valid_until = CURRENT_TIMESTAMP + INTERVAL '1 hour'
|
||||
WHERE id = 'current-a'
|
||||
""");
|
||||
assertThatThrownBy(() -> execute(
|
||||
secretInsert("retiring-b", "org-a", "connection-a", 2, "RETIRING")
|
||||
)).isInstanceOf(SQLException.class);
|
||||
}
|
||||
|
||||
private void migrateAndSeedConnections() throws SQLException {
|
||||
flyway(MigrationVersion.fromVersion("65")).migrate();
|
||||
execute("""
|
||||
INSERT INTO user_account (id, display_name, status)
|
||||
VALUES ('creator', 'Creator', 'ACTIVE')
|
||||
""");
|
||||
execute("""
|
||||
INSERT INTO organization (id, slug, display_name, created_by)
|
||||
VALUES
|
||||
('org-a', 'org-a', 'Organization A', 'creator'),
|
||||
('org-b', 'org-b', 'Organization B', 'creator')
|
||||
""");
|
||||
execute(connectionInsert("connection-a", "org-a"));
|
||||
execute(connectionInsert("connection-b", "org-b"));
|
||||
}
|
||||
|
||||
private String connectionInsert(String id, String organizationId) {
|
||||
return """
|
||||
INSERT INTO login_connection (
|
||||
id, public_handle, scope_type, organization_id,
|
||||
display_name, status, adapter_key
|
||||
) VALUES (
|
||||
'%s', '%s-handle', 'ORGANIZATION', '%s', 'OIDC', 'DRAFT', 'oidc'
|
||||
)
|
||||
""".formatted(id, id, organizationId);
|
||||
}
|
||||
|
||||
private String secretInsert(
|
||||
String id,
|
||||
String organizationId,
|
||||
String connectionId,
|
||||
long bindingVersion,
|
||||
String status
|
||||
) {
|
||||
String validUntil = status.equals("RETIRING")
|
||||
? "CURRENT_TIMESTAMP + INTERVAL '1 hour'"
|
||||
: "NULL";
|
||||
return """
|
||||
INSERT INTO login_connection_secret_version (
|
||||
id, organization_id, connection_id, purpose, binding_version,
|
||||
algorithm, key_id, nonce, ciphertext, status, valid_until, created_by
|
||||
) VALUES (
|
||||
'%s', '%s', '%s', 'login.client-secret', %d,
|
||||
'AES-256-GCM', 'key-2026-09', decode(repeat('01', 12), 'hex'),
|
||||
decode(repeat('02', 32), 'hex'), '%s', %s, 'creator'
|
||||
)
|
||||
""".formatted(
|
||||
id,
|
||||
organizationId,
|
||||
connectionId,
|
||||
bindingVersion,
|
||||
status,
|
||||
validUntil
|
||||
);
|
||||
}
|
||||
|
||||
private Flyway flyway(MigrationVersion target) {
|
||||
var configuration = Flyway.configure()
|
||||
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
||||
.cleanDisabled(false);
|
||||
if (target != null) {
|
||||
configuration.target(target);
|
||||
}
|
||||
return configuration.load();
|
||||
}
|
||||
|
||||
private void execute(String sql) throws SQLException {
|
||||
try (Connection connection = connection(); Statement statement = connection.createStatement()) {
|
||||
statement.execute(sql);
|
||||
}
|
||||
}
|
||||
|
||||
private long queryLong(String sql) throws SQLException {
|
||||
try (Connection connection = connection();
|
||||
Statement statement = connection.createStatement();
|
||||
ResultSet result = statement.executeQuery(sql)) {
|
||||
assertThat(result.next()).isTrue();
|
||||
return result.getLong(1);
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> queryStrings(String sql) throws SQLException {
|
||||
try (Connection connection = connection();
|
||||
Statement statement = connection.createStatement();
|
||||
ResultSet result = statement.executeQuery(sql)) {
|
||||
var values = new ArrayList<String>();
|
||||
while (result.next()) {
|
||||
values.add(result.getString(1));
|
||||
}
|
||||
return List.copyOf(values);
|
||||
}
|
||||
}
|
||||
|
||||
private Connection connection() throws SQLException {
|
||||
return DriverManager.getConnection(
|
||||
POSTGRES.getJdbcUrl(),
|
||||
POSTGRES.getUsername(),
|
||||
POSTGRES.getPassword()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,430 @@
|
|||
package com.iflytek.skillhub.repository;
|
||||
|
||||
import static java.nio.charset.StandardCharsets.ISO_8859_1;
|
||||
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import com.iflytek.skillhub.auth.connection.control.JpaLoginConnectionRepositoryAdapter;
|
||||
import com.iflytek.skillhub.auth.connection.control.LoginConnection;
|
||||
import com.iflytek.skillhub.auth.connection.control.LoginConnectionRepository;
|
||||
import com.iflytek.skillhub.auth.connection.core.AdapterKey;
|
||||
import com.iflytek.skillhub.auth.connection.secret.AesGcmSecretEnvelopeCipher;
|
||||
import com.iflytek.skillhub.auth.connection.secret.InMemorySecretEnvelopeKeyring;
|
||||
import com.iflytek.skillhub.auth.connection.secret.JpaLoginConnectionSecretRepositoryAdapter;
|
||||
import com.iflytek.skillhub.auth.connection.secret.LoginConnectionSecretRepository;
|
||||
import com.iflytek.skillhub.auth.connection.secret.LoginConnectionSecretService;
|
||||
import com.iflytek.skillhub.auth.connection.secret.LoginConnectionSecretStatus;
|
||||
import com.iflytek.skillhub.auth.connection.secret.SecretConfigurationSummary;
|
||||
import com.iflytek.skillhub.auth.connection.secret.SecretEnvelopeCipher;
|
||||
import com.iflytek.skillhub.auth.connection.secret.SecretEnvelopeKey;
|
||||
import com.iflytek.skillhub.auth.connection.secret.SecretMaterial;
|
||||
import com.iflytek.skillhub.auth.connection.secret.SecretMaterialUnavailableException;
|
||||
import com.iflytek.skillhub.auth.connection.secret.SecretPurpose;
|
||||
import com.iflytek.skillhub.auth.connection.secret.SecretReference;
|
||||
import com.iflytek.skillhub.domain.organization.Organization;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
|
||||
import org.springframework.boot.test.context.TestConfiguration;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
|
||||
@DataJpaTest(properties = {
|
||||
"spring.flyway.enabled=true",
|
||||
"spring.jpa.hibernate.ddl-auto=validate"
|
||||
})
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@ActiveProfiles("test")
|
||||
@Testcontainers
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
@Import({
|
||||
JpaLoginConnectionRepositoryAdapter.class,
|
||||
JpaLoginConnectionSecretRepositoryAdapter.class,
|
||||
LoginConnectionSecretService.class,
|
||||
LoginConnectionSecretPersistenceTest.SecretCipherConfiguration.class
|
||||
})
|
||||
class LoginConnectionSecretPersistenceTest {
|
||||
|
||||
private static final Instant NOW = Instant.parse("2026-09-08T06:00:00Z");
|
||||
|
||||
@Container
|
||||
private static final PostgreSQLContainer<?> POSTGRES =
|
||||
new PostgreSQLContainer<>("postgres:16-alpine");
|
||||
|
||||
@DynamicPropertySource
|
||||
static void configurePostgres(DynamicPropertyRegistry registry) {
|
||||
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
|
||||
registry.add("spring.datasource.username", POSTGRES::getUsername);
|
||||
registry.add("spring.datasource.password", POSTGRES::getPassword);
|
||||
registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver");
|
||||
registry.add(
|
||||
"spring.jpa.database-platform",
|
||||
() -> "org.hibernate.dialect.PostgreSQLDialect"
|
||||
);
|
||||
}
|
||||
|
||||
@Autowired
|
||||
private LoginConnectionRepository connectionRepository;
|
||||
|
||||
@Autowired
|
||||
private LoginConnectionSecretRepository secretRepository;
|
||||
|
||||
@Autowired
|
||||
private LoginConnectionSecretService secretService;
|
||||
|
||||
@Autowired
|
||||
private EntityManager entityManager;
|
||||
|
||||
@Autowired
|
||||
private PlatformTransactionManager transactionManager;
|
||||
|
||||
private String organizationId;
|
||||
private String actorId;
|
||||
private LoginConnection connection;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
String unique = UUID.randomUUID().toString();
|
||||
actorId = "secret-admin-" + unique;
|
||||
new TransactionTemplate(transactionManager).executeWithoutResult(ignored -> {
|
||||
UserAccount actor = new UserAccount(
|
||||
actorId,
|
||||
"Secret Admin",
|
||||
unique + "@example.com",
|
||||
null
|
||||
);
|
||||
entityManager.persist(actor);
|
||||
Organization organization = Organization.create(
|
||||
"secret-" + unique,
|
||||
"Secret Organization",
|
||||
actor.getId(),
|
||||
NOW
|
||||
);
|
||||
entityManager.persist(organization);
|
||||
organizationId = organization.getId();
|
||||
});
|
||||
connection = connectionRepository.save(LoginConnection.createOrganization(
|
||||
organizationId,
|
||||
"Corporate OIDC",
|
||||
new AdapterKey("oidc"),
|
||||
actorId,
|
||||
NOW
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
void storesOnlyAuthenticatedCiphertextAndReturnsRedactedMetadata() {
|
||||
SecretReference reference = rotate("client-secret-that-must-not-leak", Duration.ZERO, NOW);
|
||||
|
||||
Object[] stored = new TransactionTemplate(transactionManager).execute(status ->
|
||||
(Object[]) entityManager.createNativeQuery("""
|
||||
SELECT ciphertext, nonce, key_id
|
||||
FROM login_connection_secret_version
|
||||
WHERE connection_id = :connectionId AND binding_version = 1
|
||||
""")
|
||||
.setParameter("connectionId", connection.getId())
|
||||
.getSingleResult()
|
||||
);
|
||||
assertThat(new String((byte[]) stored[0], ISO_8859_1))
|
||||
.doesNotContain("client-secret-that-must-not-leak");
|
||||
assertThat((byte[]) stored[1]).hasSize(12);
|
||||
assertThat(stored[2]).isEqualTo("test-master-key");
|
||||
|
||||
try (SecretMaterial resolved = secretService.resolve(
|
||||
reference,
|
||||
SecretPurpose.LOGIN_CLIENT_SECRET,
|
||||
NOW.plusSeconds(1)
|
||||
)) {
|
||||
assertThat(read(resolved)).isEqualTo("client-secret-that-must-not-leak");
|
||||
}
|
||||
|
||||
SecretConfigurationSummary summary = secretService.describe(
|
||||
organizationId,
|
||||
connection.getId(),
|
||||
SecretPurpose.LOGIN_CLIENT_SECRET
|
||||
);
|
||||
assertThat(summary.configured()).isTrue();
|
||||
assertThat(summary.updatedAt()).isEqualTo(NOW);
|
||||
assertThat(summary.previousValidUntil()).isNull();
|
||||
assertThat(secretRepository.findAll(
|
||||
organizationId,
|
||||
connection.getId(),
|
||||
SecretPurpose.LOGIN_CLIENT_SECRET
|
||||
)).singleElement().satisfies(secret -> assertThat(secret.toString())
|
||||
.contains("envelope=<redacted>")
|
||||
.doesNotContain(
|
||||
"client-secret-that-must-not-leak",
|
||||
"test-master-key"
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rotationKeepsOnePinnedPreviousVersionForTheBoundedWindow() {
|
||||
SecretReference first = rotate("first-secret", Duration.ZERO, NOW);
|
||||
SecretReference second = rotate(
|
||||
"second-secret",
|
||||
Duration.ofHours(1),
|
||||
NOW.plusSeconds(10)
|
||||
);
|
||||
|
||||
assertThat(readResolved(first, NOW.plusSeconds(20))).isEqualTo("first-secret");
|
||||
assertThat(readResolved(second, NOW.plusSeconds(20))).isEqualTo("second-secret");
|
||||
|
||||
SecretReference third = rotate(
|
||||
"third-secret",
|
||||
Duration.ofMinutes(30),
|
||||
NOW.plusSeconds(30)
|
||||
);
|
||||
|
||||
assertUnavailable(() -> secretService.resolve(
|
||||
first,
|
||||
SecretPurpose.LOGIN_CLIENT_SECRET,
|
||||
NOW.plusSeconds(31)
|
||||
));
|
||||
assertThat(readResolved(second, NOW.plusSeconds(31))).isEqualTo("second-secret");
|
||||
assertThat(readResolved(third, NOW.plusSeconds(31))).isEqualTo("third-secret");
|
||||
assertUnavailable(() -> secretService.resolve(
|
||||
second,
|
||||
SecretPurpose.LOGIN_CLIENT_SECRET,
|
||||
NOW.plusSeconds(30).plus(Duration.ofMinutes(30))
|
||||
));
|
||||
|
||||
assertThat(secretRepository.findAll(
|
||||
organizationId,
|
||||
connection.getId(),
|
||||
SecretPurpose.LOGIN_CLIENT_SECRET
|
||||
)).extracting(secret -> secret.getStatus())
|
||||
.containsExactly(
|
||||
LoginConnectionSecretStatus.CURRENT,
|
||||
LoginConnectionSecretStatus.RETIRING,
|
||||
LoginConnectionSecretStatus.REVOKED
|
||||
);
|
||||
assertThat(secretService.describe(
|
||||
organizationId,
|
||||
connection.getId(),
|
||||
SecretPurpose.LOGIN_CLIENT_SECRET
|
||||
).previousValidUntil()).isEqualTo(NOW.plusSeconds(30).plus(Duration.ofMinutes(30)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void wrongTenantPurposeAndRevokedVersionAllFailWithTheSameSafeError() {
|
||||
SecretReference current = rotate("revocable-secret", Duration.ZERO, NOW);
|
||||
|
||||
assertUnavailable(() -> secretService.resolve(
|
||||
new SecretReference(
|
||||
"another-organization",
|
||||
connection.getId(),
|
||||
SecretPurpose.LOGIN_CLIENT_SECRET,
|
||||
current.bindingVersion()
|
||||
),
|
||||
SecretPurpose.LOGIN_CLIENT_SECRET,
|
||||
NOW.plusSeconds(1)
|
||||
));
|
||||
assertUnavailable(() -> secretService.resolve(
|
||||
current,
|
||||
new SecretPurpose("login.private-key"),
|
||||
NOW.plusSeconds(1)
|
||||
));
|
||||
|
||||
secretService.revoke(
|
||||
organizationId,
|
||||
connection.getId(),
|
||||
SecretPurpose.LOGIN_CLIENT_SECRET,
|
||||
current.bindingVersion(),
|
||||
actorId,
|
||||
NOW.plusSeconds(2)
|
||||
);
|
||||
|
||||
assertUnavailable(() -> secretService.resolve(
|
||||
current,
|
||||
SecretPurpose.LOGIN_CLIENT_SECRET,
|
||||
NOW.plusSeconds(3)
|
||||
));
|
||||
assertThat(secretService.describe(
|
||||
organizationId,
|
||||
connection.getId(),
|
||||
SecretPurpose.LOGIN_CLIENT_SECRET
|
||||
)).isEqualTo(SecretConfigurationSummary.notConfigured());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnboundedRotationWindowBeforeWritingAnything() {
|
||||
try (SecretMaterial material = SecretMaterial.copyOf("new-secret".getBytes(UTF_8))) {
|
||||
assertThatThrownBy(() -> secretService.rotate(
|
||||
organizationId,
|
||||
connection.getId(),
|
||||
SecretPurpose.LOGIN_CLIENT_SECRET,
|
||||
material,
|
||||
Duration.ofHours(25),
|
||||
actorId,
|
||||
NOW
|
||||
)).isInstanceOf(DomainBadRequestException.class)
|
||||
.hasMessage("error.loginConnection.secret.rotation.overlap.invalid");
|
||||
}
|
||||
|
||||
assertThat(secretRepository.findAll(
|
||||
organizationId,
|
||||
connection.getId(),
|
||||
SecretPurpose.LOGIN_CLIENT_SECRET
|
||||
)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void bindingVersionsStayConnectionWideAcrossDifferentPurposes() {
|
||||
SecretReference clientSecret = rotate("client-secret", Duration.ZERO, NOW);
|
||||
SecretPurpose privateKeyPurpose = new SecretPurpose("login.private-key");
|
||||
SecretReference privateKey;
|
||||
try (SecretMaterial material = SecretMaterial.copyOf("private-key".getBytes(UTF_8))) {
|
||||
privateKey = secretService.rotate(
|
||||
organizationId,
|
||||
connection.getId(),
|
||||
privateKeyPurpose,
|
||||
material,
|
||||
Duration.ZERO,
|
||||
actorId,
|
||||
NOW.plusSeconds(1)
|
||||
);
|
||||
}
|
||||
|
||||
assertThat(clientSecret.bindingVersion()).isEqualTo(1);
|
||||
assertThat(privateKey.bindingVersion()).isEqualTo(2);
|
||||
try (SecretMaterial resolved = secretService.resolve(
|
||||
privateKey,
|
||||
privateKeyPurpose,
|
||||
NOW.plusSeconds(2)
|
||||
)) {
|
||||
assertThat(read(resolved)).isEqualTo("private-key");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void concurrentRotationsAreSerializedByTheConnectionLock() throws Exception {
|
||||
rotate("initial-secret", Duration.ZERO, NOW);
|
||||
CountDownLatch ready = new CountDownLatch(2);
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
try (var executor = Executors.newFixedThreadPool(2)) {
|
||||
var first = executor.submit(() -> {
|
||||
ready.countDown();
|
||||
start.await();
|
||||
return rotate(
|
||||
"concurrent-secret-a",
|
||||
Duration.ofMinutes(10),
|
||||
NOW.plusSeconds(1)
|
||||
);
|
||||
});
|
||||
var second = executor.submit(() -> {
|
||||
ready.countDown();
|
||||
start.await();
|
||||
return rotate(
|
||||
"concurrent-secret-b",
|
||||
Duration.ofMinutes(10),
|
||||
NOW.plusSeconds(1)
|
||||
);
|
||||
});
|
||||
|
||||
assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue();
|
||||
start.countDown();
|
||||
assertThat(Set.of(
|
||||
first.get(20, TimeUnit.SECONDS).bindingVersion(),
|
||||
second.get(20, TimeUnit.SECONDS).bindingVersion()
|
||||
)).containsExactlyInAnyOrder(2L, 3L);
|
||||
}
|
||||
|
||||
assertThat(secretRepository.findAll(
|
||||
organizationId,
|
||||
connection.getId(),
|
||||
SecretPurpose.LOGIN_CLIENT_SECRET
|
||||
)).extracting(secret -> secret.getStatus())
|
||||
.containsExactly(
|
||||
LoginConnectionSecretStatus.CURRENT,
|
||||
LoginConnectionSecretStatus.RETIRING,
|
||||
LoginConnectionSecretStatus.REVOKED
|
||||
);
|
||||
}
|
||||
|
||||
private SecretReference rotate(String value, Duration overlap, Instant occurredAt) {
|
||||
try (SecretMaterial material = SecretMaterial.copyOf(value.getBytes(UTF_8))) {
|
||||
return secretService.rotate(
|
||||
organizationId,
|
||||
connection.getId(),
|
||||
SecretPurpose.LOGIN_CLIENT_SECRET,
|
||||
material,
|
||||
overlap,
|
||||
actorId,
|
||||
occurredAt
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private String readResolved(SecretReference reference, Instant now) {
|
||||
try (SecretMaterial resolved = secretService.resolve(
|
||||
reference,
|
||||
SecretPurpose.LOGIN_CLIENT_SECRET,
|
||||
now
|
||||
)) {
|
||||
return read(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
private static String read(SecretMaterial material) {
|
||||
return material.use(bytes -> new String(bytes, UTF_8));
|
||||
}
|
||||
|
||||
private static void assertUnavailable(ThrowingOperation operation) {
|
||||
assertThatThrownBy(operation::run)
|
||||
.isInstanceOf(SecretMaterialUnavailableException.class)
|
||||
.hasMessage("Secret material is unavailable")
|
||||
.hasNoCause();
|
||||
}
|
||||
|
||||
@TestConfiguration
|
||||
static class SecretCipherConfiguration {
|
||||
|
||||
@Bean(destroyMethod = "close")
|
||||
InMemorySecretEnvelopeKeyring secretEnvelopeKeyring() {
|
||||
byte[] bytes = new byte[32];
|
||||
Arrays.fill(bytes, (byte) 5);
|
||||
SecretEnvelopeKey key = new SecretEnvelopeKey("test-master-key", bytes);
|
||||
Arrays.fill(bytes, (byte) 0);
|
||||
try (key) {
|
||||
return new InMemorySecretEnvelopeKeyring(key);
|
||||
}
|
||||
}
|
||||
|
||||
@Bean
|
||||
SecretEnvelopeCipher secretEnvelopeCipher(
|
||||
InMemorySecretEnvelopeKeyring keyring
|
||||
) {
|
||||
return new AesGcmSecretEnvelopeCipher(keyring);
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface ThrowingOperation {
|
||||
void run();
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,15 @@ public class JpaLoginConnectionRepositoryAdapter implements LoginConnectionRepos
|
|||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public Optional<LoginConnection> lockByOrganizationIdAndId(String organizationId, String id) {
|
||||
return delegate.lockByOrganizationIdAndId(
|
||||
requireText(organizationId, "organizationId"),
|
||||
requireText(id, "id")
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<LoginConnection> findByPublicHandle(ConnectionHandle handle) {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ public interface LoginConnectionRepository {
|
|||
|
||||
Optional<LoginConnection> findByOrganizationIdAndId(String organizationId, String id);
|
||||
|
||||
/** Serializes high-risk control-plane mutations such as Secret rotation. */
|
||||
Optional<LoginConnection> lockByOrganizationIdAndId(String organizationId, String id);
|
||||
|
||||
/** Dedicated data-plane lookup; the random handle reveals no tenant management identifier. */
|
||||
Optional<LoginConnection> findByPublicHandle(ConnectionHandle handle);
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.iflytek.skillhub.auth.connection.control;
|
|||
|
||||
import com.iflytek.skillhub.auth.connection.core.AdapterKey;
|
||||
import com.iflytek.skillhub.auth.connection.core.ConnectionHandle;
|
||||
import com.iflytek.skillhub.auth.connection.secret.SecretReference;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
|
|
@ -17,7 +18,7 @@ public record LoginConnectionRevisionSnapshot(
|
|||
int configSchemaVersion,
|
||||
String capabilitiesJson,
|
||||
String typedConfigJson,
|
||||
Optional<Long> secretBindingVersion
|
||||
Optional<SecretReference> secretReference
|
||||
) {
|
||||
|
||||
public LoginConnectionRevisionSnapshot {
|
||||
|
|
@ -36,7 +37,15 @@ public record LoginConnectionRevisionSnapshot(
|
|||
}
|
||||
capabilitiesJson = requireText(capabilitiesJson, "capabilitiesJson");
|
||||
typedConfigJson = requireText(typedConfigJson, "typedConfigJson");
|
||||
secretBindingVersion = Objects.requireNonNull(secretBindingVersion, "secretBindingVersion");
|
||||
secretReference = Objects.requireNonNull(secretReference, "secretReference");
|
||||
if (secretReference.isPresent()) {
|
||||
SecretReference reference = secretReference.get();
|
||||
String expectedScope = organizationId.orElse(SecretReference.PLATFORM_SCOPE_KEY);
|
||||
if (!expectedScope.equals(reference.scopeKey())
|
||||
|| !connectionId.equals(reference.connectionId())) {
|
||||
throw new IllegalArgumentException("Secret reference scope is invalid");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String requireText(String value, String field) {
|
||||
|
|
@ -59,7 +68,7 @@ public record LoginConnectionRevisionSnapshot(
|
|||
+ ", adapterKey=" + adapterKey.value()
|
||||
+ ", adapterContractVersion=" + adapterContractVersion
|
||||
+ ", configSchemaVersion=" + configSchemaVersion
|
||||
+ ", capabilitiesJson=<redacted>, typedConfigJson=<redacted>, secretBindingVersion="
|
||||
+ (secretBindingVersion.isPresent() ? "present" : "none") + "]";
|
||||
+ ", capabilitiesJson=<redacted>, typedConfigJson=<redacted>, secretReference="
|
||||
+ (secretReference.isPresent() ? "present" : "none") + "]";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,27 @@
|
|||
package com.iflytek.skillhub.auth.connection.control;
|
||||
|
||||
import java.util.Optional;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
/** Internal Spring Data delegate; callers use the tenant-scoped repository port. */
|
||||
interface LoginConnectionSpringDataRepository extends JpaRepository<LoginConnection, String> {
|
||||
|
||||
Optional<LoginConnection> findByOrganizationIdAndId(String organizationId, String id);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("""
|
||||
select connection
|
||||
from LoginConnection connection
|
||||
where connection.organizationId = :organizationId and connection.id = :id
|
||||
""")
|
||||
Optional<LoginConnection> lockByOrganizationIdAndId(
|
||||
@Param("organizationId") String organizationId,
|
||||
@Param("id") String id
|
||||
);
|
||||
|
||||
Optional<LoginConnection> findByPublicHandle(String publicHandle);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package com.iflytek.skillhub.auth.connection.control;
|
||||
|
||||
import com.iflytek.skillhub.auth.connection.core.ConnectionUnavailableException;
|
||||
import com.iflytek.skillhub.auth.connection.secret.SecretPurpose;
|
||||
import com.iflytek.skillhub.auth.connection.secret.SecretReference;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
|
|
@ -84,7 +86,7 @@ public class StoredLoginConnectionRevision {
|
|||
this.configSchemaVersion = configSchemaVersion;
|
||||
this.capabilities = requireJsonShape(capabilities, '[', ']', "capabilities");
|
||||
this.typedConfig = requireJsonShape(typedConfig, '{', '}', "typedConfig");
|
||||
if (secretBindingVersion != null && secretBindingVersion < 0) {
|
||||
if (secretBindingVersion != null && secretBindingVersion < 1) {
|
||||
throw invalid("secretBindingVersion");
|
||||
}
|
||||
this.secretBindingVersion = secretBindingVersion;
|
||||
|
|
@ -132,10 +134,22 @@ public class StoredLoginConnectionRevision {
|
|||
configSchemaVersion,
|
||||
capabilities,
|
||||
typedConfig,
|
||||
Optional.ofNullable(secretBindingVersion)
|
||||
secretReference(connection)
|
||||
);
|
||||
}
|
||||
|
||||
private Optional<SecretReference> secretReference(LoginConnection connection) {
|
||||
if (secretBindingVersion == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(new SecretReference(
|
||||
connection.getOrganizationId().orElse(SecretReference.PLATFORM_SCOPE_KEY),
|
||||
connectionId,
|
||||
SecretPurpose.LOGIN_CLIENT_SECRET,
|
||||
secretBindingVersion
|
||||
));
|
||||
}
|
||||
|
||||
private static String requireContractVersion(String value) {
|
||||
String normalized = requireText(value, "adapterContractVersion");
|
||||
if (!CONTRACT_VERSION.matcher(normalized).matches()) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,130 @@
|
|||
package com.iflytek.skillhub.auth.connection.secret;
|
||||
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
/** AES-256-GCM local Secret envelope implementation with context-bound authenticated data. */
|
||||
public final class AesGcmSecretEnvelopeCipher implements SecretEnvelopeCipher {
|
||||
|
||||
public static final String ALGORITHM = "AES-256-GCM";
|
||||
static final int NONCE_BYTES = 12;
|
||||
static final int TAG_BYTES = 16;
|
||||
private static final int TAG_BITS = TAG_BYTES * Byte.SIZE;
|
||||
private static final String TRANSFORMATION = "AES/GCM/NoPadding";
|
||||
|
||||
private final SecretEnvelopeKeyring keyring;
|
||||
private final SecureRandom secureRandom;
|
||||
|
||||
public AesGcmSecretEnvelopeCipher(SecretEnvelopeKeyring keyring) {
|
||||
this(keyring, new SecureRandom());
|
||||
}
|
||||
|
||||
AesGcmSecretEnvelopeCipher(SecretEnvelopeKeyring keyring, SecureRandom secureRandom) {
|
||||
this.keyring = Objects.requireNonNull(keyring, "keyring");
|
||||
this.secureRandom = Objects.requireNonNull(secureRandom, "secureRandom");
|
||||
}
|
||||
|
||||
@Override
|
||||
public EncryptedSecretEnvelope encrypt(
|
||||
SecretBindingContext context,
|
||||
SecretMaterial plaintext
|
||||
) {
|
||||
Objects.requireNonNull(context, "context");
|
||||
Objects.requireNonNull(plaintext, "plaintext");
|
||||
try (SecretEnvelopeKey key = keyring.activeKey()) {
|
||||
byte[] nonce = new byte[NONCE_BYTES];
|
||||
secureRandom.nextBytes(nonce);
|
||||
byte[] ciphertext = plaintext.use(bytes -> encrypt(
|
||||
bytes,
|
||||
key,
|
||||
nonce,
|
||||
context.authenticatedData(ALGORITHM, key.keyId())
|
||||
));
|
||||
return new EncryptedSecretEnvelope(ALGORITHM, key.keyId(), nonce, ciphertext);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public SecretMaterial decrypt(
|
||||
SecretBindingContext context,
|
||||
EncryptedSecretEnvelope envelope
|
||||
) {
|
||||
Objects.requireNonNull(context, "context");
|
||||
Objects.requireNonNull(envelope, "envelope");
|
||||
if (!ALGORITHM.equals(envelope.algorithm())) {
|
||||
throw new SecretMaterialUnavailableException();
|
||||
}
|
||||
SecretEnvelopeKey key = keyring.findById(envelope.keyId())
|
||||
.orElseThrow(SecretMaterialUnavailableException::new);
|
||||
try (key) {
|
||||
byte[] plaintext = decrypt(
|
||||
envelope.ciphertext(),
|
||||
key,
|
||||
envelope.nonce(),
|
||||
context.authenticatedData(envelope.algorithm(), envelope.keyId())
|
||||
);
|
||||
try {
|
||||
return SecretMaterial.copyOf(plaintext);
|
||||
} finally {
|
||||
Arrays.fill(plaintext, (byte) 0);
|
||||
}
|
||||
} catch (SecretMaterialUnavailableException exception) {
|
||||
throw exception;
|
||||
} catch (RuntimeException exception) {
|
||||
throw new SecretMaterialUnavailableException();
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] encrypt(
|
||||
byte[] plaintext,
|
||||
SecretEnvelopeKey key,
|
||||
byte[] nonce,
|
||||
byte[] authenticatedData
|
||||
) {
|
||||
try {
|
||||
return crypt(Cipher.ENCRYPT_MODE, plaintext, key, nonce, authenticatedData);
|
||||
} catch (GeneralSecurityException exception) {
|
||||
throw new IllegalStateException("Unable to encrypt Secret material", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] decrypt(
|
||||
byte[] ciphertext,
|
||||
SecretEnvelopeKey key,
|
||||
byte[] nonce,
|
||||
byte[] authenticatedData
|
||||
) {
|
||||
try {
|
||||
return crypt(Cipher.DECRYPT_MODE, ciphertext, key, nonce, authenticatedData);
|
||||
} catch (GeneralSecurityException exception) {
|
||||
throw new SecretMaterialUnavailableException();
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] crypt(
|
||||
int mode,
|
||||
byte[] input,
|
||||
SecretEnvelopeKey key,
|
||||
byte[] nonce,
|
||||
byte[] authenticatedData
|
||||
) throws GeneralSecurityException {
|
||||
byte[] keyBytes = key.copyKeyBytes();
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
|
||||
cipher.init(
|
||||
mode,
|
||||
new SecretKeySpec(keyBytes, "AES"),
|
||||
new GCMParameterSpec(TAG_BITS, nonce)
|
||||
);
|
||||
cipher.updateAAD(authenticatedData);
|
||||
return cipher.doFinal(input);
|
||||
} finally {
|
||||
Arrays.fill(keyBytes, (byte) 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
package com.iflytek.skillhub.auth.connection.secret;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/** Authenticated ciphertext persisted at rest; byte accessors always return defensive copies. */
|
||||
public final class EncryptedSecretEnvelope {
|
||||
|
||||
private final String algorithm;
|
||||
private final String keyId;
|
||||
private final byte[] nonce;
|
||||
private final byte[] ciphertext;
|
||||
|
||||
public EncryptedSecretEnvelope(
|
||||
String algorithm,
|
||||
String keyId,
|
||||
byte[] nonce,
|
||||
byte[] ciphertext
|
||||
) {
|
||||
this.algorithm = requireText(algorithm, "algorithm");
|
||||
this.keyId = requireText(keyId, "keyId");
|
||||
this.nonce = Objects.requireNonNull(nonce, "nonce").clone();
|
||||
this.ciphertext = Objects.requireNonNull(ciphertext, "ciphertext").clone();
|
||||
if (this.nonce.length != AesGcmSecretEnvelopeCipher.NONCE_BYTES) {
|
||||
throw new IllegalArgumentException("Secret envelope nonce length is invalid");
|
||||
}
|
||||
if (this.ciphertext.length <= AesGcmSecretEnvelopeCipher.TAG_BYTES) {
|
||||
throw new IllegalArgumentException("Secret envelope ciphertext length is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
public String algorithm() {
|
||||
return algorithm;
|
||||
}
|
||||
|
||||
public String keyId() {
|
||||
return keyId;
|
||||
}
|
||||
|
||||
public byte[] nonce() {
|
||||
return nonce.clone();
|
||||
}
|
||||
|
||||
public byte[] ciphertext() {
|
||||
return ciphertext.clone();
|
||||
}
|
||||
|
||||
private static String requireText(String value, String field) {
|
||||
Objects.requireNonNull(value, field);
|
||||
String normalized = value.trim();
|
||||
if (normalized.isEmpty()) {
|
||||
throw new IllegalArgumentException(field + " must not be blank");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "EncryptedSecretEnvelope[<redacted>]";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
package com.iflytek.skillhub.auth.connection.secret;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Process-local keyring useful for composition and tests; callers receive disposable key copies. */
|
||||
public final class InMemorySecretEnvelopeKeyring implements SecretEnvelopeKeyring, AutoCloseable {
|
||||
|
||||
private final String activeKeyId;
|
||||
private final Map<String, byte[]> keys;
|
||||
private boolean closed;
|
||||
|
||||
public InMemorySecretEnvelopeKeyring(SecretEnvelopeKey activeKey, SecretEnvelopeKey... oldKeys) {
|
||||
Objects.requireNonNull(activeKey, "activeKey");
|
||||
Objects.requireNonNull(oldKeys, "oldKeys");
|
||||
this.activeKeyId = activeKey.keyId();
|
||||
this.keys = new LinkedHashMap<>();
|
||||
putCopy(activeKey);
|
||||
for (SecretEnvelopeKey oldKey : oldKeys) {
|
||||
putCopy(Objects.requireNonNull(oldKey, "oldKey"));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized SecretEnvelopeKey activeKey() {
|
||||
return copy(activeKeyId).orElseThrow(() -> new IllegalStateException("Secret keyring is closed"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized Optional<SecretEnvelopeKey> findById(String keyId) {
|
||||
Objects.requireNonNull(keyId, "keyId");
|
||||
return copy(keyId);
|
||||
}
|
||||
|
||||
private Optional<SecretEnvelopeKey> copy(String keyId) {
|
||||
if (closed) {
|
||||
return Optional.empty();
|
||||
}
|
||||
byte[] bytes = keys.get(keyId);
|
||||
return bytes == null
|
||||
? Optional.empty()
|
||||
: Optional.of(new SecretEnvelopeKey(keyId, bytes));
|
||||
}
|
||||
|
||||
private void putCopy(SecretEnvelopeKey key) {
|
||||
byte[] copy = key.copyKeyBytes();
|
||||
byte[] previous = keys.putIfAbsent(key.keyId(), copy);
|
||||
if (previous != null) {
|
||||
Arrays.fill(copy, (byte) 0);
|
||||
throw new IllegalArgumentException("Duplicate Secret envelope key id");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void close() {
|
||||
if (!closed) {
|
||||
keys.values().forEach(bytes -> Arrays.fill(bytes, (byte) 0));
|
||||
keys.clear();
|
||||
closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "InMemorySecretEnvelopeKeyring[<redacted>]";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
package com.iflytek.skillhub.auth.connection.secret;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/** JPA adapter that exposes no unscoped lookup and no raw Secret material. */
|
||||
@Repository
|
||||
public class JpaLoginConnectionSecretRepositoryAdapter
|
||||
implements LoginConnectionSecretRepository {
|
||||
|
||||
private final LoginConnectionSecretSpringDataRepository delegate;
|
||||
|
||||
public JpaLoginConnectionSecretRepositoryAdapter(
|
||||
LoginConnectionSecretSpringDataRepository delegate
|
||||
) {
|
||||
this.delegate = Objects.requireNonNull(delegate, "delegate");
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public List<LoginConnectionSecretVersion> findAllForUpdate(
|
||||
String scopeKey,
|
||||
String connectionId
|
||||
) {
|
||||
return delegate.findAllForUpdate(
|
||||
requireText(scopeKey, "scopeKey"),
|
||||
requireText(connectionId, "connectionId")
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<LoginConnectionSecretVersion> findAll(
|
||||
String scopeKey,
|
||||
String connectionId,
|
||||
SecretPurpose purpose
|
||||
) {
|
||||
return delegate.findAllByScopeKeyAndConnectionIdAndPurposeOrderByBindingVersionDesc(
|
||||
requireText(scopeKey, "scopeKey"),
|
||||
requireText(connectionId, "connectionId"),
|
||||
Objects.requireNonNull(purpose, "purpose").value()
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<LoginConnectionSecretVersion> findByReference(SecretReference reference) {
|
||||
SecretReference requiredReference = Objects.requireNonNull(reference, "reference");
|
||||
return delegate.findByScopeKeyAndConnectionIdAndPurposeAndBindingVersion(
|
||||
requiredReference.scopeKey(),
|
||||
requiredReference.connectionId(),
|
||||
requiredReference.purpose().value(),
|
||||
requiredReference.bindingVersion()
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public LoginConnectionSecretVersion saveAndFlush(
|
||||
LoginConnectionSecretVersion secretVersion
|
||||
) {
|
||||
return delegate.saveAndFlush(Objects.requireNonNull(secretVersion, "secretVersion"));
|
||||
}
|
||||
|
||||
private static String requireText(String value, String field) {
|
||||
Objects.requireNonNull(value, field);
|
||||
String normalized = value.trim();
|
||||
if (normalized.isEmpty()) {
|
||||
throw new IllegalArgumentException(field + " must not be blank");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package com.iflytek.skillhub.auth.connection.secret;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Tenant-scoped Secret persistence port; raw envelope access stays inside the Secret package. */
|
||||
public interface LoginConnectionSecretRepository {
|
||||
|
||||
List<LoginConnectionSecretVersion> findAllForUpdate(
|
||||
String scopeKey,
|
||||
String connectionId
|
||||
);
|
||||
|
||||
List<LoginConnectionSecretVersion> findAll(
|
||||
String scopeKey,
|
||||
String connectionId,
|
||||
SecretPurpose purpose
|
||||
);
|
||||
|
||||
Optional<LoginConnectionSecretVersion> findByReference(SecretReference reference);
|
||||
|
||||
LoginConnectionSecretVersion saveAndFlush(LoginConnectionSecretVersion secretVersion);
|
||||
}
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
package com.iflytek.skillhub.auth.connection.secret;
|
||||
|
||||
import com.iflytek.skillhub.auth.connection.control.LoginConnectionRepository;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/** Transactional control plane and privacy-preserving data plane for Login Secrets. */
|
||||
public class LoginConnectionSecretService implements SecretMaterialResolver {
|
||||
|
||||
public static final Duration MAX_ROTATION_OVERLAP = Duration.ofHours(24);
|
||||
|
||||
private final LoginConnectionRepository connectionRepository;
|
||||
private final LoginConnectionSecretRepository secretRepository;
|
||||
private final SecretEnvelopeCipher envelopeCipher;
|
||||
|
||||
public LoginConnectionSecretService(
|
||||
LoginConnectionRepository connectionRepository,
|
||||
LoginConnectionSecretRepository secretRepository,
|
||||
SecretEnvelopeCipher envelopeCipher
|
||||
) {
|
||||
this.connectionRepository = Objects.requireNonNull(
|
||||
connectionRepository,
|
||||
"connectionRepository"
|
||||
);
|
||||
this.secretRepository = Objects.requireNonNull(secretRepository, "secretRepository");
|
||||
this.envelopeCipher = Objects.requireNonNull(envelopeCipher, "envelopeCipher");
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public SecretReference rotate(
|
||||
String organizationId,
|
||||
String connectionId,
|
||||
SecretPurpose purpose,
|
||||
SecretMaterial newMaterial,
|
||||
Duration overlap,
|
||||
String actorId,
|
||||
Instant occurredAt
|
||||
) {
|
||||
String tenant = requireText(organizationId, "organizationId");
|
||||
String connection = requireText(connectionId, "connectionId");
|
||||
SecretPurpose requiredPurpose = Objects.requireNonNull(purpose, "purpose");
|
||||
SecretMaterial material = Objects.requireNonNull(newMaterial, "newMaterial");
|
||||
Duration rotationOverlap = requireOverlap(overlap);
|
||||
String actor = requireText(actorId, "actorId");
|
||||
Instant now = Objects.requireNonNull(occurredAt, "occurredAt");
|
||||
|
||||
connectionRepository.lockByOrganizationIdAndId(tenant, connection)
|
||||
.orElseThrow(() -> new DomainBadRequestException(
|
||||
"error.loginConnection.notFound"
|
||||
));
|
||||
List<LoginConnectionSecretVersion> existing =
|
||||
secretRepository.findAllForUpdate(tenant, connection);
|
||||
long nextVersion = existing.stream()
|
||||
.map(LoginConnectionSecretVersion::getBindingVersion)
|
||||
.max(Comparator.naturalOrder())
|
||||
.orElse(0L) + 1;
|
||||
|
||||
existing.stream()
|
||||
.filter(secret -> secret.getPurpose().equals(requiredPurpose))
|
||||
.filter(secret -> secret.getStatus() == LoginConnectionSecretStatus.RETIRING)
|
||||
.forEach(secret -> {
|
||||
secret.revoke(actor, now);
|
||||
secretRepository.saveAndFlush(secret);
|
||||
});
|
||||
existing.stream()
|
||||
.filter(secret -> secret.getPurpose().equals(requiredPurpose))
|
||||
.filter(secret -> secret.getStatus() == LoginConnectionSecretStatus.CURRENT)
|
||||
.findFirst()
|
||||
.ifPresent(secret -> {
|
||||
if (rotationOverlap.isZero()) {
|
||||
secret.revoke(actor, now);
|
||||
} else {
|
||||
secret.retireUntil(now.plus(rotationOverlap), now);
|
||||
}
|
||||
secretRepository.saveAndFlush(secret);
|
||||
});
|
||||
|
||||
SecretBindingContext context = new SecretBindingContext(
|
||||
tenant,
|
||||
connection,
|
||||
requiredPurpose,
|
||||
nextVersion
|
||||
);
|
||||
EncryptedSecretEnvelope envelope = envelopeCipher.encrypt(context, material);
|
||||
LoginConnectionSecretVersion stored = secretRepository.saveAndFlush(
|
||||
LoginConnectionSecretVersion.current(
|
||||
tenant,
|
||||
connection,
|
||||
requiredPurpose,
|
||||
nextVersion,
|
||||
envelope,
|
||||
actor,
|
||||
now
|
||||
)
|
||||
);
|
||||
return stored.reference();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public SecretConfigurationSummary describe(
|
||||
String organizationId,
|
||||
String connectionId,
|
||||
SecretPurpose purpose
|
||||
) {
|
||||
List<LoginConnectionSecretVersion> versions = secretRepository.findAll(
|
||||
requireText(organizationId, "organizationId"),
|
||||
requireText(connectionId, "connectionId"),
|
||||
Objects.requireNonNull(purpose, "purpose")
|
||||
);
|
||||
LoginConnectionSecretVersion current = versions.stream()
|
||||
.filter(secret -> secret.getStatus() == LoginConnectionSecretStatus.CURRENT)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
if (current == null) {
|
||||
return SecretConfigurationSummary.notConfigured();
|
||||
}
|
||||
Instant previousValidUntil = versions.stream()
|
||||
.filter(secret -> secret.getStatus() == LoginConnectionSecretStatus.RETIRING)
|
||||
.map(LoginConnectionSecretVersion::getValidUntil)
|
||||
.filter(Objects::nonNull)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
return SecretConfigurationSummary.configured(current.getUpdatedAt(), previousValidUntil);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void revoke(
|
||||
String organizationId,
|
||||
String connectionId,
|
||||
SecretPurpose purpose,
|
||||
long bindingVersion,
|
||||
String actorId,
|
||||
Instant occurredAt
|
||||
) {
|
||||
String tenant = requireText(organizationId, "organizationId");
|
||||
String connection = requireText(connectionId, "connectionId");
|
||||
SecretPurpose requiredPurpose = Objects.requireNonNull(purpose, "purpose");
|
||||
connectionRepository.lockByOrganizationIdAndId(tenant, connection)
|
||||
.orElseThrow(() -> new DomainBadRequestException(
|
||||
"error.loginConnection.notFound"
|
||||
));
|
||||
LoginConnectionSecretVersion secret = secretRepository.findAllForUpdate(tenant, connection)
|
||||
.stream()
|
||||
.filter(candidate -> candidate.getPurpose().equals(requiredPurpose))
|
||||
.filter(candidate -> candidate.getBindingVersion() == bindingVersion)
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new DomainBadRequestException(
|
||||
"error.loginConnection.secret.notFound"
|
||||
));
|
||||
secret.revoke(
|
||||
requireText(actorId, "actorId"),
|
||||
Objects.requireNonNull(occurredAt, "occurredAt")
|
||||
);
|
||||
secretRepository.saveAndFlush(secret);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public SecretMaterial resolve(
|
||||
SecretReference reference,
|
||||
SecretPurpose expectedPurpose,
|
||||
Instant now
|
||||
) {
|
||||
SecretReference requiredReference = Objects.requireNonNull(reference, "reference");
|
||||
if (!requiredReference.purpose().equals(
|
||||
Objects.requireNonNull(expectedPurpose, "expectedPurpose")
|
||||
)) {
|
||||
throw new SecretMaterialUnavailableException();
|
||||
}
|
||||
LoginConnectionSecretVersion secret = secretRepository.findByReference(requiredReference)
|
||||
.filter(candidate -> candidate.isResolvableAt(
|
||||
Objects.requireNonNull(now, "now")
|
||||
))
|
||||
.orElseThrow(SecretMaterialUnavailableException::new);
|
||||
try {
|
||||
return envelopeCipher.decrypt(requiredReference.context(), secret.envelope());
|
||||
} catch (SecretMaterialUnavailableException exception) {
|
||||
throw exception;
|
||||
} catch (RuntimeException exception) {
|
||||
throw new SecretMaterialUnavailableException();
|
||||
}
|
||||
}
|
||||
|
||||
private static Duration requireOverlap(Duration overlap) {
|
||||
Duration required = Objects.requireNonNull(overlap, "overlap");
|
||||
if (required.isNegative() || required.compareTo(MAX_ROTATION_OVERLAP) > 0) {
|
||||
throw new DomainBadRequestException(
|
||||
"error.loginConnection.secret.rotation.overlap.invalid"
|
||||
);
|
||||
}
|
||||
return required;
|
||||
}
|
||||
|
||||
private static String requireText(String value, String field) {
|
||||
Objects.requireNonNull(value, field);
|
||||
String normalized = value.trim();
|
||||
if (normalized.isEmpty()) {
|
||||
throw new IllegalArgumentException(field + " must not be blank");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
package com.iflytek.skillhub.auth.connection.secret;
|
||||
|
||||
import jakarta.persistence.LockModeType;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
/** Internal Spring Data delegate for encrypted Login Connection Secret rows. */
|
||||
interface LoginConnectionSecretSpringDataRepository
|
||||
extends JpaRepository<LoginConnectionSecretVersion, String> {
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("""
|
||||
select secretVersion
|
||||
from LoginConnectionSecretVersion secretVersion
|
||||
where secretVersion.scopeKey = :scopeKey
|
||||
and secretVersion.connectionId = :connectionId
|
||||
order by secretVersion.bindingVersion desc
|
||||
""")
|
||||
List<LoginConnectionSecretVersion> findAllForUpdate(
|
||||
@Param("scopeKey") String scopeKey,
|
||||
@Param("connectionId") String connectionId
|
||||
);
|
||||
|
||||
List<LoginConnectionSecretVersion>
|
||||
findAllByScopeKeyAndConnectionIdAndPurposeOrderByBindingVersionDesc(
|
||||
String scopeKey,
|
||||
String connectionId,
|
||||
String purpose
|
||||
);
|
||||
|
||||
Optional<LoginConnectionSecretVersion>
|
||||
findByScopeKeyAndConnectionIdAndPurposeAndBindingVersion(
|
||||
String scopeKey,
|
||||
String connectionId,
|
||||
String purpose,
|
||||
long bindingVersion
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.iflytek.skillhub.auth.connection.secret;
|
||||
|
||||
/** Runtime eligibility of one immutable encrypted Secret version. */
|
||||
public enum LoginConnectionSecretStatus {
|
||||
CURRENT,
|
||||
RETIRING,
|
||||
REVOKED
|
||||
}
|
||||
|
|
@ -0,0 +1,246 @@
|
|||
package com.iflytek.skillhub.auth.connection.secret;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.Version;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Encrypted-at-rest Secret version whose raw envelope is restricted to this package. */
|
||||
@Entity
|
||||
@Table(name = "login_connection_secret_version")
|
||||
public class LoginConnectionSecretVersion {
|
||||
|
||||
@Id
|
||||
@Column(length = 64)
|
||||
private String id;
|
||||
|
||||
@Column(name = "organization_id", length = 64, updatable = false)
|
||||
private String organizationId;
|
||||
|
||||
@Column(name = "scope_key", length = 64, insertable = false, updatable = false)
|
||||
private String scopeKey;
|
||||
|
||||
@Column(name = "connection_id", nullable = false, length = 64, updatable = false)
|
||||
private String connectionId;
|
||||
|
||||
@Column(nullable = false, length = 128, updatable = false)
|
||||
private String purpose;
|
||||
|
||||
@Column(name = "binding_version", nullable = false, updatable = false)
|
||||
private long bindingVersion;
|
||||
|
||||
@Column(nullable = false, length = 32, updatable = false)
|
||||
private String algorithm;
|
||||
|
||||
@Column(name = "key_id", nullable = false, length = 128, updatable = false)
|
||||
private String keyId;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
private byte[] nonce;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
private byte[] ciphertext;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 32)
|
||||
private LoginConnectionSecretStatus status;
|
||||
|
||||
@Column(name = "valid_until")
|
||||
private Instant validUntil;
|
||||
|
||||
@Column(name = "created_by", nullable = false, length = 128, updatable = false)
|
||||
private String createdBy;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private Instant updatedAt;
|
||||
|
||||
@Column(name = "revoked_by", length = 128)
|
||||
private String revokedBy;
|
||||
|
||||
@Column(name = "revoked_at")
|
||||
private Instant revokedAt;
|
||||
|
||||
@Version
|
||||
@Column(nullable = false)
|
||||
private long version;
|
||||
|
||||
protected LoginConnectionSecretVersion() {
|
||||
}
|
||||
|
||||
private LoginConnectionSecretVersion(
|
||||
String organizationId,
|
||||
String connectionId,
|
||||
SecretPurpose purpose,
|
||||
long bindingVersion,
|
||||
EncryptedSecretEnvelope envelope,
|
||||
String createdBy,
|
||||
Instant createdAt
|
||||
) {
|
||||
this.id = UUID.randomUUID().toString();
|
||||
this.organizationId = organizationId == null
|
||||
? null
|
||||
: requireText(organizationId, "organizationId");
|
||||
this.connectionId = requireText(connectionId, "connectionId");
|
||||
this.purpose = Objects.requireNonNull(purpose, "purpose").value();
|
||||
if (bindingVersion < 1) {
|
||||
throw new IllegalArgumentException("bindingVersion must be positive");
|
||||
}
|
||||
this.bindingVersion = bindingVersion;
|
||||
EncryptedSecretEnvelope requiredEnvelope = Objects.requireNonNull(envelope, "envelope");
|
||||
this.algorithm = requiredEnvelope.algorithm();
|
||||
this.keyId = requiredEnvelope.keyId();
|
||||
this.nonce = requiredEnvelope.nonce();
|
||||
this.ciphertext = requiredEnvelope.ciphertext();
|
||||
this.status = LoginConnectionSecretStatus.CURRENT;
|
||||
this.createdBy = requireText(createdBy, "createdBy");
|
||||
this.createdAt = Objects.requireNonNull(createdAt, "createdAt");
|
||||
this.updatedAt = createdAt;
|
||||
}
|
||||
|
||||
static LoginConnectionSecretVersion current(
|
||||
String organizationId,
|
||||
String connectionId,
|
||||
SecretPurpose purpose,
|
||||
long bindingVersion,
|
||||
EncryptedSecretEnvelope envelope,
|
||||
String createdBy,
|
||||
Instant createdAt
|
||||
) {
|
||||
return new LoginConnectionSecretVersion(
|
||||
organizationId,
|
||||
connectionId,
|
||||
purpose,
|
||||
bindingVersion,
|
||||
envelope,
|
||||
createdBy,
|
||||
createdAt
|
||||
);
|
||||
}
|
||||
|
||||
void retireUntil(Instant retirementDeadline, Instant occurredAt) {
|
||||
if (status != LoginConnectionSecretStatus.CURRENT) {
|
||||
throw new IllegalStateException("Only the current Secret can enter retirement");
|
||||
}
|
||||
Instant transitionTime = requireCurrentOrLater(occurredAt);
|
||||
Instant deadline = Objects.requireNonNull(retirementDeadline, "retirementDeadline");
|
||||
if (!deadline.isAfter(transitionTime)) {
|
||||
throw new IllegalArgumentException("Retirement deadline must be in the future");
|
||||
}
|
||||
status = LoginConnectionSecretStatus.RETIRING;
|
||||
validUntil = deadline;
|
||||
updatedAt = transitionTime;
|
||||
}
|
||||
|
||||
void revoke(String actorId, Instant occurredAt) {
|
||||
if (status == LoginConnectionSecretStatus.REVOKED) {
|
||||
return;
|
||||
}
|
||||
Instant transitionTime = requireCurrentOrLater(occurredAt);
|
||||
status = LoginConnectionSecretStatus.REVOKED;
|
||||
revokedBy = requireText(actorId, "actorId");
|
||||
revokedAt = transitionTime;
|
||||
updatedAt = transitionTime;
|
||||
}
|
||||
|
||||
boolean isResolvableAt(Instant instant) {
|
||||
Instant now = Objects.requireNonNull(instant, "instant");
|
||||
return status == LoginConnectionSecretStatus.CURRENT
|
||||
|| (status == LoginConnectionSecretStatus.RETIRING
|
||||
&& validUntil != null
|
||||
&& now.isBefore(validUntil));
|
||||
}
|
||||
|
||||
EncryptedSecretEnvelope envelope() {
|
||||
return new EncryptedSecretEnvelope(algorithm, keyId, nonce, ciphertext);
|
||||
}
|
||||
|
||||
SecretReference reference() {
|
||||
return new SecretReference(
|
||||
organizationId == null ? SecretReference.PLATFORM_SCOPE_KEY : organizationId,
|
||||
connectionId,
|
||||
new SecretPurpose(purpose),
|
||||
bindingVersion
|
||||
);
|
||||
}
|
||||
|
||||
private Instant requireCurrentOrLater(Instant occurredAt) {
|
||||
Instant transitionTime = Objects.requireNonNull(occurredAt, "occurredAt");
|
||||
if (transitionTime.isBefore(updatedAt)) {
|
||||
throw new IllegalArgumentException("Secret transition timestamp is stale");
|
||||
}
|
||||
return transitionTime;
|
||||
}
|
||||
|
||||
private static String requireText(String value, String field) {
|
||||
Objects.requireNonNull(value, field);
|
||||
String normalized = value.trim();
|
||||
if (normalized.isEmpty()) {
|
||||
throw new IllegalArgumentException(field + " must not be blank");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getOrganizationId() {
|
||||
return organizationId;
|
||||
}
|
||||
|
||||
public String getConnectionId() {
|
||||
return connectionId;
|
||||
}
|
||||
|
||||
public SecretPurpose getPurpose() {
|
||||
return new SecretPurpose(purpose);
|
||||
}
|
||||
|
||||
public long getBindingVersion() {
|
||||
return bindingVersion;
|
||||
}
|
||||
|
||||
public LoginConnectionSecretStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public Instant getValidUntil() {
|
||||
return validUntil;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public Instant getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
public Instant getRevokedAt() {
|
||||
return revokedAt;
|
||||
}
|
||||
|
||||
public long getVersion() {
|
||||
return version;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "LoginConnectionSecretVersion[id=" + id
|
||||
+ ", organizationId=" + organizationId
|
||||
+ ", connectionId=" + connectionId
|
||||
+ ", purpose=" + purpose
|
||||
+ ", bindingVersion=" + bindingVersion
|
||||
+ ", status=" + status
|
||||
+ ", envelope=<redacted>]";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
package com.iflytek.skillhub.auth.connection.secret;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Coordinates cryptographically bound into a Secret envelope as authenticated data. */
|
||||
public record SecretBindingContext(
|
||||
String scopeKey,
|
||||
String connectionId,
|
||||
SecretPurpose purpose,
|
||||
long bindingVersion
|
||||
) {
|
||||
|
||||
public SecretBindingContext {
|
||||
scopeKey = requireText(scopeKey, "scopeKey");
|
||||
connectionId = requireText(connectionId, "connectionId");
|
||||
purpose = Objects.requireNonNull(purpose, "purpose");
|
||||
if (bindingVersion < 1) {
|
||||
throw new IllegalArgumentException("bindingVersion must be positive");
|
||||
}
|
||||
}
|
||||
|
||||
byte[] authenticatedData(String algorithm, String keyId) {
|
||||
return String.join(
|
||||
"\n",
|
||||
"skillhub-login-secret-v1",
|
||||
scopeKey,
|
||||
connectionId,
|
||||
purpose.value(),
|
||||
Long.toString(bindingVersion),
|
||||
requireText(algorithm, "algorithm"),
|
||||
requireText(keyId, "keyId")
|
||||
).getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static String requireText(String value, String field) {
|
||||
Objects.requireNonNull(value, field);
|
||||
String normalized = value.trim();
|
||||
if (normalized.isEmpty() || normalized.indexOf('\n') >= 0 || normalized.indexOf('\r') >= 0) {
|
||||
throw new IllegalArgumentException(field + " is invalid");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
package com.iflytek.skillhub.auth.connection.secret;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Redacted management projection safe for repository and future API boundaries. */
|
||||
public record SecretConfigurationSummary(
|
||||
boolean configured,
|
||||
Instant updatedAt,
|
||||
Instant previousValidUntil
|
||||
) {
|
||||
|
||||
public SecretConfigurationSummary {
|
||||
if (configured) {
|
||||
Objects.requireNonNull(updatedAt, "updatedAt");
|
||||
} else if (updatedAt != null || previousValidUntil != null) {
|
||||
throw new IllegalArgumentException("Unconfigured Secret cannot expose timestamps");
|
||||
}
|
||||
}
|
||||
|
||||
public static SecretConfigurationSummary configured(
|
||||
Instant updatedAt,
|
||||
Instant previousValidUntil
|
||||
) {
|
||||
return new SecretConfigurationSummary(
|
||||
true,
|
||||
Objects.requireNonNull(updatedAt, "updatedAt"),
|
||||
previousValidUntil
|
||||
);
|
||||
}
|
||||
|
||||
public static SecretConfigurationSummary notConfigured() {
|
||||
return new SecretConfigurationSummary(false, null, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "SecretConfigurationSummary[configured=" + configured
|
||||
+ ", updatedAt=" + updatedAt
|
||||
+ ", previousValidUntil=" + previousValidUntil + "]";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package com.iflytek.skillhub.auth.connection.secret;
|
||||
|
||||
/** Authenticated encryption seam used by Secret persistence. */
|
||||
public interface SecretEnvelopeCipher {
|
||||
|
||||
EncryptedSecretEnvelope encrypt(SecretBindingContext context, SecretMaterial plaintext);
|
||||
|
||||
SecretMaterial decrypt(SecretBindingContext context, EncryptedSecretEnvelope envelope);
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
package com.iflytek.skillhub.auth.connection.secret;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** Erasable AES-256 envelope key identified by a non-secret rotation key id. */
|
||||
public final class SecretEnvelopeKey implements AutoCloseable {
|
||||
|
||||
private static final Pattern KEY_ID_FORMAT = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{2,127}");
|
||||
private static final int AES_256_KEY_BYTES = 32;
|
||||
|
||||
private final String keyId;
|
||||
private byte[] keyBytes;
|
||||
|
||||
public SecretEnvelopeKey(String keyId, byte[] keyBytes) {
|
||||
Objects.requireNonNull(keyId, "keyId");
|
||||
String normalizedKeyId = keyId.trim();
|
||||
if (!KEY_ID_FORMAT.matcher(normalizedKeyId).matches()) {
|
||||
throw new IllegalArgumentException("Secret envelope key id format is invalid");
|
||||
}
|
||||
Objects.requireNonNull(keyBytes, "keyBytes");
|
||||
if (keyBytes.length != AES_256_KEY_BYTES) {
|
||||
throw new IllegalArgumentException("Secret envelope key must contain 32 bytes");
|
||||
}
|
||||
this.keyId = normalizedKeyId;
|
||||
this.keyBytes = keyBytes.clone();
|
||||
}
|
||||
|
||||
public String keyId() {
|
||||
return keyId;
|
||||
}
|
||||
|
||||
public synchronized byte[] copyKeyBytes() {
|
||||
if (keyBytes == null) {
|
||||
throw new IllegalStateException("Secret envelope key is closed");
|
||||
}
|
||||
return keyBytes.clone();
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void close() {
|
||||
if (keyBytes != null) {
|
||||
Arrays.fill(keyBytes, (byte) 0);
|
||||
keyBytes = null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "SecretEnvelopeKey[<redacted>]";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package com.iflytek.skillhub.auth.connection.secret;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/** Key-provider seam for local encrypted storage or an externally managed KMS/Vault. */
|
||||
public interface SecretEnvelopeKeyring {
|
||||
|
||||
SecretEnvelopeKey activeKey();
|
||||
|
||||
Optional<SecretEnvelopeKey> findById(String keyId);
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
package com.iflytek.skillhub.auth.connection.secret;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Function;
|
||||
|
||||
/** Short-lived, explicitly erasable Secret bytes with no raw accessor. */
|
||||
public final class SecretMaterial implements AutoCloseable {
|
||||
|
||||
private byte[] bytes;
|
||||
|
||||
private SecretMaterial(byte[] bytes) {
|
||||
if (bytes.length == 0) {
|
||||
throw new IllegalArgumentException("Secret material must not be empty");
|
||||
}
|
||||
this.bytes = bytes;
|
||||
}
|
||||
|
||||
public static SecretMaterial copyOf(byte[] source) {
|
||||
Objects.requireNonNull(source, "source");
|
||||
return new SecretMaterial(source.clone());
|
||||
}
|
||||
|
||||
public synchronized <T> T use(Function<byte[], T> operation) {
|
||||
Objects.requireNonNull(operation, "operation");
|
||||
if (bytes == null) {
|
||||
throw new IllegalStateException("Secret material is closed");
|
||||
}
|
||||
byte[] workingCopy = bytes.clone();
|
||||
try {
|
||||
return operation.apply(workingCopy);
|
||||
} finally {
|
||||
Arrays.fill(workingCopy, (byte) 0);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void close() {
|
||||
if (bytes != null) {
|
||||
Arrays.fill(bytes, (byte) 0);
|
||||
bytes = null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "SecretMaterial[<redacted>]";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package com.iflytek.skillhub.auth.connection.secret;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/** Runtime-only Secret material boundary used by protocol Adapters. */
|
||||
public interface SecretMaterialResolver {
|
||||
|
||||
SecretMaterial resolve(
|
||||
SecretReference reference,
|
||||
SecretPurpose expectedPurpose,
|
||||
Instant now
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package com.iflytek.skillhub.auth.connection.secret;
|
||||
|
||||
/** Privacy-preserving data-plane failure shared by all unavailable Secret causes. */
|
||||
public final class SecretMaterialUnavailableException extends RuntimeException {
|
||||
|
||||
public SecretMaterialUnavailableException() {
|
||||
super("Secret material is unavailable");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package com.iflytek.skillhub.auth.connection.secret;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** Stable, extensible purpose key used to prevent Secret reuse across protocols. */
|
||||
public record SecretPurpose(String value) {
|
||||
|
||||
private static final Pattern FORMAT = Pattern.compile("[a-z][a-z0-9._-]{2,127}");
|
||||
|
||||
public static final SecretPurpose LOGIN_CLIENT_SECRET =
|
||||
new SecretPurpose("login.client-secret");
|
||||
|
||||
public SecretPurpose {
|
||||
Objects.requireNonNull(value, "value");
|
||||
value = value.trim();
|
||||
if (!FORMAT.matcher(value).matches()) {
|
||||
throw new IllegalArgumentException("Secret purpose format is invalid");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package com.iflytek.skillhub.auth.connection.secret;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/** Non-secret, version-pinned reference stored by an immutable connection revision. */
|
||||
public record SecretReference(
|
||||
String scopeKey,
|
||||
String connectionId,
|
||||
SecretPurpose purpose,
|
||||
long bindingVersion
|
||||
) {
|
||||
|
||||
public static final String PLATFORM_SCOPE_KEY = "@platform";
|
||||
|
||||
public SecretReference {
|
||||
scopeKey = requireText(scopeKey, "scopeKey");
|
||||
connectionId = requireText(connectionId, "connectionId");
|
||||
purpose = Objects.requireNonNull(purpose, "purpose");
|
||||
if (bindingVersion < 1) {
|
||||
throw new IllegalArgumentException("bindingVersion must be positive");
|
||||
}
|
||||
}
|
||||
|
||||
SecretBindingContext context() {
|
||||
return new SecretBindingContext(scopeKey, connectionId, purpose, bindingVersion);
|
||||
}
|
||||
|
||||
private static String requireText(String value, String field) {
|
||||
Objects.requireNonNull(value, field);
|
||||
String normalized = value.trim();
|
||||
if (normalized.isEmpty()) {
|
||||
throw new IllegalArgumentException(field + " must not be blank");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
/** Encrypted, version-pinned and redacted Secret storage for enterprise connections. */
|
||||
package com.iflytek.skillhub.auth.connection.secret;
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
package com.iflytek.skillhub.auth.connection.control;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.iflytek.skillhub.auth.connection.core.AdapterKey;
|
||||
import com.iflytek.skillhub.auth.connection.secret.SecretPurpose;
|
||||
import com.iflytek.skillhub.auth.connection.secret.SecretReference;
|
||||
import java.time.Instant;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class StoredLoginConnectionRevisionSecretReferenceTest {
|
||||
|
||||
private static final Instant NOW = Instant.parse("2026-09-08T06:00:00Z");
|
||||
|
||||
@Test
|
||||
void materializationExpandsBindingVersionIntoTenantBoundClientSecretReference() {
|
||||
LoginConnection connection = LoginConnection.createOrganization(
|
||||
"organization-1",
|
||||
"Corporate OIDC",
|
||||
new AdapterKey("oidc"),
|
||||
"identity-admin",
|
||||
NOW
|
||||
);
|
||||
StoredLoginConnectionRevision revision = revision(connection, 7L);
|
||||
|
||||
assertThat(revision.snapshot(connection).secretReference()).get().satisfies(reference -> {
|
||||
assertThat(reference.scopeKey()).isEqualTo("organization-1");
|
||||
assertThat(reference.connectionId()).isEqualTo(connection.getId());
|
||||
assertThat(reference.purpose()).isEqualTo(SecretPurpose.LOGIN_CLIENT_SECRET);
|
||||
assertThat(reference.bindingVersion()).isEqualTo(7);
|
||||
});
|
||||
assertThat(revision.snapshot(connection).toString())
|
||||
.contains("secretReference=present")
|
||||
.doesNotContain("bindingVersion=7");
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformRevisionMaterializesTheReservedPlatformSecretScope() {
|
||||
LoginConnection platform = LoginConnection.createPlatform(
|
||||
"github",
|
||||
"GitHub",
|
||||
new AdapterKey("oauth"),
|
||||
"platform-admin",
|
||||
NOW
|
||||
);
|
||||
|
||||
assertThat(revision(platform, 1L).snapshot(platform).secretReference())
|
||||
.get()
|
||||
.extracting(reference -> reference.scopeKey())
|
||||
.isEqualTo(SecretReference.PLATFORM_SCOPE_KEY);
|
||||
}
|
||||
|
||||
private static StoredLoginConnectionRevision revision(
|
||||
LoginConnection connection,
|
||||
Long secretBindingVersion
|
||||
) {
|
||||
return StoredLoginConnectionRevision.create(
|
||||
connection.getId(),
|
||||
1,
|
||||
"1.0",
|
||||
1,
|
||||
"[\"IDENTITY_ASSERTION\"]",
|
||||
"{\"issuer\":\"https://id.example.com\"}",
|
||||
secretBindingVersion,
|
||||
"identity-admin",
|
||||
NOW
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
package com.iflytek.skillhub.auth.connection.secret;
|
||||
|
||||
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Arrays;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class AesGcmSecretEnvelopeCipherTest {
|
||||
|
||||
private static final SecretBindingContext CONTEXT = new SecretBindingContext(
|
||||
"organization-1",
|
||||
"connection-1",
|
||||
SecretPurpose.LOGIN_CLIENT_SECRET,
|
||||
1
|
||||
);
|
||||
|
||||
private final InMemorySecretEnvelopeKeyring keyring = keyring("key-2026-09", (byte) 7);
|
||||
private final AesGcmSecretEnvelopeCipher cipher =
|
||||
new AesGcmSecretEnvelopeCipher(keyring, new SecureRandom());
|
||||
|
||||
@AfterEach
|
||||
void closeKeyring() {
|
||||
keyring.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void encryptsWithRandomNonceAndDecryptsOnlyInTheBoundContext() {
|
||||
EncryptedSecretEnvelope first;
|
||||
EncryptedSecretEnvelope second;
|
||||
try (SecretMaterial secret = SecretMaterial.copyOf("client-secret-value".getBytes(UTF_8))) {
|
||||
first = cipher.encrypt(CONTEXT, secret);
|
||||
second = cipher.encrypt(CONTEXT, secret);
|
||||
}
|
||||
|
||||
assertThat(first.nonce()).hasSize(12).isNotEqualTo(second.nonce());
|
||||
assertThat(first.ciphertext()).isNotEqualTo(second.ciphertext());
|
||||
try (SecretMaterial decrypted = cipher.decrypt(CONTEXT, first)) {
|
||||
assertThat(read(decrypted)).isEqualTo("client-secret-value");
|
||||
}
|
||||
|
||||
SecretBindingContext anotherConnection = new SecretBindingContext(
|
||||
"organization-1",
|
||||
"connection-2",
|
||||
SecretPurpose.LOGIN_CLIENT_SECRET,
|
||||
1
|
||||
);
|
||||
assertUnavailable(() -> cipher.decrypt(anotherConnection, first));
|
||||
}
|
||||
|
||||
@Test
|
||||
void failsClosedForTamperingOrUnknownMasterKeyWithoutLeakingDetails() {
|
||||
EncryptedSecretEnvelope encrypted;
|
||||
try (SecretMaterial secret = SecretMaterial.copyOf("do-not-log-me".getBytes(UTF_8))) {
|
||||
encrypted = cipher.encrypt(CONTEXT, secret);
|
||||
}
|
||||
byte[] tampered = encrypted.ciphertext();
|
||||
tampered[tampered.length - 1] ^= 1;
|
||||
EncryptedSecretEnvelope changed = new EncryptedSecretEnvelope(
|
||||
encrypted.algorithm(),
|
||||
encrypted.keyId(),
|
||||
encrypted.nonce(),
|
||||
tampered
|
||||
);
|
||||
|
||||
assertUnavailable(() -> cipher.decrypt(CONTEXT, changed));
|
||||
|
||||
try (InMemorySecretEnvelopeKeyring anotherKeyring = keyring("another-key", (byte) 9)) {
|
||||
AesGcmSecretEnvelopeCipher anotherCipher =
|
||||
new AesGcmSecretEnvelopeCipher(anotherKeyring, new SecureRandom());
|
||||
assertUnavailable(() -> anotherCipher.decrypt(CONTEXT, encrypted));
|
||||
}
|
||||
|
||||
assertThat(encrypted.toString())
|
||||
.doesNotContain("do-not-log-me", encrypted.keyId())
|
||||
.contains("<redacted>");
|
||||
}
|
||||
|
||||
@Test
|
||||
void secretAndKeyBuffersAreDefensivelyCopiedAndWipedOnClose() {
|
||||
byte[] source = "short-lived-secret".getBytes(UTF_8);
|
||||
SecretMaterial material = SecretMaterial.copyOf(source);
|
||||
Arrays.fill(source, (byte) 0);
|
||||
|
||||
assertThat(read(material)).isEqualTo("short-lived-secret");
|
||||
assertThat(material.toString()).isEqualTo("SecretMaterial[<redacted>]");
|
||||
material.close();
|
||||
|
||||
assertThatThrownBy(() -> read(material))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessage("Secret material is closed");
|
||||
|
||||
byte[] keyBytes = new byte[32];
|
||||
Arrays.fill(keyBytes, (byte) 3);
|
||||
SecretEnvelopeKey key = new SecretEnvelopeKey("key-copy-test", keyBytes);
|
||||
Arrays.fill(keyBytes, (byte) 0);
|
||||
assertThat(key.copyKeyBytes()).containsOnly((byte) 3);
|
||||
assertThat(key.toString()).isEqualTo("SecretEnvelopeKey[<redacted>]");
|
||||
key.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void keyringRotationCanDecryptOldEnvelopeWithoutReusingTheRetiredKeyForWrites() {
|
||||
EncryptedSecretEnvelope oldEnvelope;
|
||||
try (SecretMaterial material = SecretMaterial.copyOf("old-envelope".getBytes(UTF_8))) {
|
||||
oldEnvelope = cipher.encrypt(CONTEXT, material);
|
||||
}
|
||||
|
||||
SecretEnvelopeKey newKey = key("key-2026-10", (byte) 10);
|
||||
SecretEnvelopeKey oldKey = key("key-2026-09", (byte) 7);
|
||||
try (newKey;
|
||||
oldKey;
|
||||
InMemorySecretEnvelopeKeyring rotatedKeyring =
|
||||
new InMemorySecretEnvelopeKeyring(newKey, oldKey)) {
|
||||
AesGcmSecretEnvelopeCipher rotatedCipher =
|
||||
new AesGcmSecretEnvelopeCipher(rotatedKeyring, new SecureRandom());
|
||||
try (SecretMaterial decrypted = rotatedCipher.decrypt(CONTEXT, oldEnvelope)) {
|
||||
assertThat(read(decrypted)).isEqualTo("old-envelope");
|
||||
}
|
||||
try (SecretMaterial next = SecretMaterial.copyOf("new-envelope".getBytes(UTF_8))) {
|
||||
assertThat(rotatedCipher.encrypt(CONTEXT, next).keyId())
|
||||
.isEqualTo("key-2026-10");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String read(SecretMaterial material) {
|
||||
return material.use(bytes -> new String(bytes, UTF_8));
|
||||
}
|
||||
|
||||
private static InMemorySecretEnvelopeKeyring keyring(String keyId, byte value) {
|
||||
SecretEnvelopeKey key = key(keyId, value);
|
||||
try (key) {
|
||||
return new InMemorySecretEnvelopeKeyring(key);
|
||||
}
|
||||
}
|
||||
|
||||
private static SecretEnvelopeKey key(String keyId, byte value) {
|
||||
byte[] bytes = new byte[32];
|
||||
Arrays.fill(bytes, value);
|
||||
SecretEnvelopeKey key = new SecretEnvelopeKey(keyId, bytes);
|
||||
Arrays.fill(bytes, (byte) 0);
|
||||
return key;
|
||||
}
|
||||
|
||||
private static void assertUnavailable(ThrowingOperation operation) {
|
||||
assertThatThrownBy(operation::run)
|
||||
.isInstanceOf(SecretMaterialUnavailableException.class)
|
||||
.hasMessage("Secret material is unavailable")
|
||||
.hasNoCause();
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface ThrowingOperation {
|
||||
void run();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package com.iflytek.skillhub.auth.connection.secret;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.time.Instant;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class SecretConfigurationSummaryTest {
|
||||
|
||||
@Test
|
||||
void managementDtoAndToStringExposeOnlyNonSecretMetadata() throws Exception {
|
||||
SecretConfigurationSummary summary = SecretConfigurationSummary.configured(
|
||||
Instant.parse("2026-09-08T06:00:00Z"),
|
||||
Instant.parse("2026-09-08T07:00:00Z")
|
||||
);
|
||||
|
||||
String json = new ObjectMapper().findAndRegisterModules().writeValueAsString(summary);
|
||||
|
||||
assertThat(json)
|
||||
.contains("\"configured\":true", "\"updatedAt\"", "\"previousValidUntil\"")
|
||||
.doesNotContain("value", "ciphertext", "nonce", "keyId", "clientSecret");
|
||||
assertThat(summary.toString())
|
||||
.doesNotContain("ciphertext", "nonce", "keyId", "clientSecret");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue