merge: compose current auth migration with Ticket case schema 91

This commit is contained in:
2026-10-11 02:21:52 +09:00
64 changed files with 5196 additions and 4551 deletions
@@ -32,7 +32,8 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
tx.execute_batch(
"CREATE TEMP TABLE repository_key_cutover (
workspace_id TEXT NOT NULL, kind TEXT NOT NULL, resource_id TEXT NOT NULL,
legacy_counter INTEGER NOT NULL, operation_id TEXT NOT NULL, fingerprint TEXT NOT NULL, created_at TEXT NOT NULL,
legacy_counter INTEGER NOT NULL, operation_id TEXT NOT NULL, fingerprint TEXT NOT NULL,
created_at_seconds INTEGER NOT NULL, created_at_subsec_nanos INTEGER NOT NULL,
PRIMARY KEY(workspace_id,kind,resource_id,legacy_counter));",
)?;
for (table, kind, id, fingerprint) in [
@@ -68,15 +69,20 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
for (workspace, resource, legacy_counter, fingerprint, created) in rows {
let mut statement = tx.prepare("SELECT operation_id FROM repository_secret_operations WHERE workspace_id=?1 AND resource_kind=?2 AND resource_id=?3 AND result_revision=?4 AND created_at=?5")?;
let matches = statement
.query_map(params![workspace, kind, resource, legacy_counter, created], |r| {
r.get::<_, String>(0)
})?
.query_map(
params![workspace, kind, resource, legacy_counter, created],
|r| r.get::<_, String>(0),
)?
.collect::<rusqlite::Result<Vec<_>>>()?;
if matches.len() != 1 || matches[0].is_empty() {
return Err(Error::Store("Repository key cutover requires one unambiguous mutation receipt per stored key".into()));
}
let created_time = frozen_repository_timestamp(
&created,
&format!("{table}.created_at (workspace {workspace}, resource {resource})"),
)?;
tx.execute(
"INSERT INTO repository_key_cutover VALUES(?1,?2,?3,?4,?5,?6,?7)",
"INSERT INTO repository_key_cutover VALUES(?1,?2,?3,?4,?5,?6,?7,?8)",
params![
workspace,
kind,
@@ -84,11 +90,26 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
legacy_counter,
matches[0],
fingerprint,
created
created_time.timestamp(),
created_time.timestamp_subsec_nanos()
],
)?;
}
}
// Candidate copying uses an INNER JOIN: reject corrupt source rows before
// they can disappear and evade the destination foreign-key check.
let orphaned_candidates: bool = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM workdir_create_credential_candidates c
LEFT JOIN workdir_create_operations o ON o.workspace_id=c.workspace_id AND o.operation_id=c.operation_id
WHERE o.operation_id IS NULL)",
[],
|row| row.get(0),
)?;
if orphaned_candidates {
return Err(Error::Store(
"Repository key cutover found credential candidates without a Workdir create operation".into(),
));
}
// Current rows must agree with their pointed-to historical key, not merely
// resolve to a nonempty receipt. FK checks alone cannot prove this identity.
let inconsistent: bool = tx.query_row("SELECT EXISTS (
@@ -148,8 +169,17 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
})?
.collect::<rusqlite::Result<Vec<_>>>()?
};
for (workspace, id, legacy_counter, purpose, algorithm, nonce, ciphertext, created, operation) in
secrets
for (
workspace,
id,
legacy_counter,
purpose,
algorithm,
nonce,
ciphertext,
created,
operation,
) in secrets
{
if algorithm != "aes-256-gcm-v1" {
return Err(Error::Store(
@@ -233,8 +263,9 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
SELECT o.workspace_id,o.operation_id FROM workdir_create_operations_v86 o
WHERE o.state='succeeded'
AND NOT EXISTS(SELECT 1 FROM workdir_create_credential_revision_retentions_v86 r WHERE r.workspace_id=o.workspace_id AND r.operation_id=o.operation_id)
AND (o.credential_id IS NOT NULL OR o.host_trust_id IS NOT NULL OR EXISTS(SELECT 1 FROM workdir_create_credential_candidates_v86 c WHERE c.workspace_id=o.workspace_id AND c.operation_id=o.operation_id));
INSERT INTO repository_secret_legacy_receipts
AND (o.credential_id IS NOT NULL OR o.host_trust_id IS NOT NULL OR EXISTS(SELECT 1 FROM workdir_create_credential_candidates_v86 c WHERE c.workspace_id=o.workspace_id AND c.operation_id=o.operation_id));")?;
prepare_repository_cutover_times(&tx)?;
tx.execute_batch("INSERT INTO repository_secret_legacy_receipts
SELECT e.workspace_id,e.operation_id,e.request_fingerprint,e.resource_kind,e.resource_id,e.result_revision,e.created_at,e.mutation_kind,
CASE WHEN e.mutation_kind IN ('credential_created','host_trust_created') THEN 0
WHEN e.mutation_kind IN ('credential_rotated','host_trust_rotated') AND e.result_revision>1 THEN e.result_revision-1
@@ -244,8 +275,9 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
WHERE a.workspace_id=o.workspace_id AND a.resource_id=o.resource_id AND a.revision=o.result_revision AND a.created_at=o.created_at
AND ((o.resource_kind='credential' AND a.kind LIKE 'credential_%') OR (o.resource_kind='host_trust' AND a.kind LIKE 'host_trust_%'))
HAVING count(*)=1) AS mutation_kind FROM repository_secret_operations_v86 o) e
JOIN repository_cutover_times t ON t.kind='receipt' AND t.workspace_id=e.workspace_id AND t.operation_id=e.operation_id
LEFT JOIN repository_key_cutover m ON m.workspace_id=e.workspace_id AND m.kind=e.resource_kind AND m.resource_id=e.resource_id
AND m.created_at<=e.created_at
AND (m.created_at_seconds,m.created_at_subsec_nanos)<=(t.seconds,t.subsec_nanos)
AND m.legacy_counter=CASE WHEN e.mutation_kind IN ('credential_rotated','host_trust_rotated') THEN e.result_revision-1
WHEN e.mutation_kind IN ('credential_deleted','host_trust_deleted') THEN e.result_revision END;
INSERT INTO workdir_create_operations
@@ -257,12 +289,14 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
CASE WHEN a.operation_id IS NULL THEN o.repository_access_mode END
FROM workdir_create_operations_v86 o
LEFT JOIN workdir_terminal_archive_cutover a ON a.workspace_id=o.workspace_id AND a.operation_id=o.operation_id
LEFT JOIN repository_key_cutover c ON c.workspace_id=o.workspace_id AND c.kind='credential' AND c.resource_id=o.credential_id AND c.legacy_counter=o.credential_revision AND c.created_at<=o.updated_at
LEFT JOIN repository_key_cutover h ON h.workspace_id=o.workspace_id AND h.kind='host_trust' AND h.resource_id=o.host_trust_id AND h.legacy_counter=o.host_trust_revision AND h.created_at<=o.updated_at;
LEFT JOIN repository_cutover_times t ON t.kind='workdir' AND t.workspace_id=o.workspace_id AND t.operation_id=o.operation_id
LEFT JOIN repository_key_cutover c ON c.workspace_id=o.workspace_id AND c.kind='credential' AND c.resource_id=o.credential_id AND c.legacy_counter=o.credential_revision AND (c.created_at_seconds,c.created_at_subsec_nanos)<=(t.seconds,t.subsec_nanos)
LEFT JOIN repository_key_cutover h ON h.workspace_id=o.workspace_id AND h.kind='host_trust' AND h.resource_id=o.host_trust_id AND h.legacy_counter=o.host_trust_revision AND (h.created_at_seconds,h.created_at_subsec_nanos)<=(t.seconds,t.subsec_nanos);
INSERT INTO workdir_create_credential_candidates
SELECT c.workspace_id,c.operation_id,c.ordinal,c.role,c.credential_id,m.fingerprint
FROM workdir_create_credential_candidates_v86 c JOIN workdir_create_operations_v86 o ON o.workspace_id=c.workspace_id AND o.operation_id=c.operation_id LEFT JOIN repository_key_cutover m
ON m.workspace_id=c.workspace_id AND m.kind='credential' AND m.resource_id=c.credential_id AND m.legacy_counter=c.credential_revision AND m.created_at<=o.updated_at
FROM workdir_create_credential_candidates_v86 c JOIN workdir_create_operations_v86 o ON o.workspace_id=c.workspace_id AND o.operation_id=c.operation_id
JOIN repository_cutover_times t ON t.kind='workdir' AND t.workspace_id=o.workspace_id AND t.operation_id=o.operation_id
LEFT JOIN repository_key_cutover m ON m.workspace_id=c.workspace_id AND m.kind='credential' AND m.resource_id=c.credential_id AND m.legacy_counter=c.credential_revision AND (m.created_at_seconds,m.created_at_subsec_nanos)<=(t.seconds,t.subsec_nanos)
WHERE NOT EXISTS(SELECT 1 FROM workdir_terminal_archive_cutover a WHERE a.workspace_id=c.workspace_id AND a.operation_id=c.operation_id);
INSERT INTO workdir_create_credential_retentions SELECT c.workspace_id,c.operation_id,c.ordinal,c.credential_id,m.fingerprint,m.operation_id FROM workdir_create_credential_revision_retentions_v86 c LEFT JOIN repository_key_cutover m ON m.workspace_id=c.workspace_id AND m.kind='credential' AND m.resource_id=c.credential_id AND m.legacy_counter=c.credential_revision;")?;
let missing:i64=tx.query_row("SELECT count(*) FROM workdir_create_operations WHERE (credential_id IS NOT NULL AND credential_fingerprint IS NULL) OR (host_trust_id IS NOT NULL AND host_trust_fingerprint IS NULL)",[],|r|r.get(0))?;
@@ -313,7 +347,7 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
] {
tx.execute_batch(&format!("DROP TABLE {table}_v86;"))?;
}
tx.execute_batch("DROP TABLE repository_key_cutover;
tx.execute_batch("DROP TABLE repository_key_cutover; DROP TABLE repository_cutover_times;
CREATE INDEX idx_repository_secret_audit_workspace_created ON repository_secret_audit_events(workspace_id,created_at,event_id);
CREATE INDEX idx_repository_ssh_credentials_workspace_status ON repository_ssh_credentials(workspace_id,status,credential_id);
CREATE INDEX idx_repository_ssh_host_trusts_workspace_host ON repository_ssh_host_trusts(workspace_id,hostname,port);")?;
@@ -360,6 +394,109 @@ BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
result.and(restore)
}
// Schema 86 used RFC3339 for Repository secrets and epoch-millisecond text for
// registry operations. Compare typed instants, never their persisted spelling.
fn frozen_repository_timestamp(
value: &str,
context: &str,
) -> Result<chrono::DateTime<chrono::Utc>> {
if let Ok(timestamp) = chrono::DateTime::parse_from_rfc3339(value) {
return Ok(timestamp.with_timezone(&chrono::Utc));
}
if let Ok(millis) = value.parse::<i64>()
&& let Some(timestamp) = chrono::DateTime::from_timestamp_millis(millis)
{
return Ok(timestamp);
}
Err(Error::Store(format!(
"Repository key migration found invalid timestamp in {context}"
)))
}
fn prepare_repository_cutover_times(tx: &rusqlite::Transaction<'_>) -> Result<()> {
// These temporary normalized values never rewrite audit history or the
// original timestamps used to identify receipts and encrypted envelopes.
// Seconds + nanoseconds also preserve sub-millisecond ordering and offsets.
tx.execute_batch(
"CREATE TEMP TABLE repository_cutover_times (
kind TEXT NOT NULL, workspace_id TEXT NOT NULL, operation_id TEXT NOT NULL,
seconds INTEGER NOT NULL, subsec_nanos INTEGER NOT NULL,
PRIMARY KEY(kind,workspace_id,operation_id));",
)?;
for (kind, context, query) in [
(
"receipt",
"repository_secret_operations.created_at",
"SELECT workspace_id,operation_id,created_at FROM repository_secret_operations_v86",
),
(
"workdir",
"workdir_create_operations.updated_at",
"SELECT o.workspace_id,o.operation_id,o.updated_at FROM workdir_create_operations_v86 o
WHERE NOT EXISTS(SELECT 1 FROM workdir_terminal_archive_cutover a
WHERE a.workspace_id=o.workspace_id AND a.operation_id=o.operation_id)
AND (o.credential_id IS NOT NULL OR o.host_trust_id IS NOT NULL OR
EXISTS(SELECT 1 FROM workdir_create_credential_candidates_v86 c
WHERE c.workspace_id=o.workspace_id AND c.operation_id=o.operation_id))",
),
] {
let rows = tx
.prepare(query)?
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
for (workspace, operation, value) in rows {
let timestamp = frozen_repository_timestamp(
&value,
&format!("{context} (workspace {workspace}, operation {operation})"),
)?;
tx.execute(
"INSERT INTO repository_cutover_times VALUES(?1,?2,?3,?4,?5)",
params![
kind,
workspace,
operation,
timestamp.timestamp(),
timestamp.timestamp_subsec_nanos()
],
)?;
}
}
// Refuse missing or newer/reused keys with a domain error before a NULL
// fingerprint reaches a NOT NULL column. Archived successes are excluded:
// they retain opaque history, not authority to retry with a current key.
let missing: Option<(String, String)> = tx.query_row(
"SELECT r.workspace_id,r.operation_id FROM (
SELECT workspace_id,operation_id,'credential' AS kind,credential_id AS resource_id,credential_revision AS legacy_counter
FROM workdir_create_operations_v86 WHERE credential_id IS NOT NULL
UNION
SELECT workspace_id,operation_id,'host_trust',host_trust_id,host_trust_revision
FROM workdir_create_operations_v86 WHERE host_trust_id IS NOT NULL
UNION
SELECT workspace_id,operation_id,'credential',credential_id,credential_revision
FROM workdir_create_credential_candidates_v86
) r
JOIN repository_cutover_times t ON t.kind='workdir' AND t.workspace_id=r.workspace_id AND t.operation_id=r.operation_id
LEFT JOIN repository_key_cutover k ON k.workspace_id=r.workspace_id AND k.kind=r.kind
AND k.resource_id=r.resource_id AND k.legacy_counter=r.legacy_counter
AND (k.created_at_seconds,k.created_at_subsec_nanos)<=(t.seconds,t.subsec_nanos)
WHERE k.operation_id IS NULL LIMIT 1",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
).optional()?;
if let Some((workspace, operation)) = missing {
return Err(Error::Store(format!(
"Workdir SSH snapshot is missing key identity evidence at or before its timestamp (workspace {workspace}, operation {operation})"
)));
}
Ok(())
}
// Preserve every SQLite field and NULL verbatim as typed JSON. Frozen archives
// are never read by credential lookup, lease construction, or a live CAS.
fn frozen_repository_json_rows(
@@ -32,12 +32,12 @@ fn legacy_repository_keys(path: &std::path::Path) -> Connection {
let end = start + frozen[start..].find(end).unwrap();
conn.execute_batch(&frozen[start..end]).unwrap();
}
conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','create-key','intent','credential','key',7,'created');
INSERT INTO repository_ssh_credentials VALUES('space','key','Key','ssh-ed25519','fingerprint',7,'active','created',NULL);
INSERT INTO repository_ssh_credential_revisions VALUES('space','key',7,'ssh-ed25519','fingerprint','created');
INSERT INTO repository_secret_audit_events VALUES('space','audit','credential_created','key',7,'actor','created');
conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','create-key','intent','credential','key',7,'2026-01-01T00:00:00.000Z');
INSERT INTO repository_ssh_credentials VALUES('space','key','Key','ssh-ed25519','fingerprint',7,'active','2026-01-01T00:00:00.000Z',NULL);
INSERT INTO repository_ssh_credential_revisions VALUES('space','key',7,'ssh-ed25519','fingerprint','2026-01-01T00:00:00.000Z');
INSERT INTO repository_secret_audit_events VALUES('space','audit','credential_created','key',7,'actor','2026-01-01T00:00:00.000Z');
INSERT INTO workdir_create_operations(workspace_id,operation_id,request_fingerprint,repository_id,resolved_runtime_id,config_revision,config_projection_digest,working_directory_id,state,created_at,updated_at,credential_id,credential_revision)
VALUES('space','create-workdir','intent','repo','runtime',3,'projection','workdir','pending','created','created','key',7);
VALUES('space','create-workdir','intent','repo','runtime',3,'projection','workdir','pending','2026-01-01T00:00:00.000Z','2026-01-01T00:00:00.000Z','key',7);
INSERT INTO workdir_create_credential_candidates VALUES('space','create-workdir',0,'primary','key',7);
INSERT INTO workdir_create_credential_revision_retentions VALUES('space','create-workdir',0,'key',7);
CREATE TRIGGER workdir_create_insert_blocked_by_runtime_removal
@@ -59,11 +59,300 @@ fn legacy_repository_keys(path: &std::path::Path) -> Connection {
&mut ciphertext,
)
.unwrap();
conn.execute("INSERT INTO server_secret_versions VALUES('space','key',7,?1,'aes-256-gcm-v1',?2,?3,'created')",params![purpose,nonce.as_slice(),ciphertext]).unwrap();
conn.execute("INSERT INTO server_secret_versions VALUES('space','key',7,?1,'aes-256-gcm-v1',?2,?3,'2026-01-01T00:00:00.000Z')",params![purpose,nonce.as_slice(),ciphertext]).unwrap();
}
conn
}
// Exercise the actual persisted formats, including failed creates whose SSH
// snapshot remains retry authority. The encrypted fixture uses the real old AAD.
fn legacy_ssh_snapshot_times(conn: &Connection, key_created: &str, workdir_updated: &str) {
conn.execute(
"UPDATE repository_secret_operations SET created_at=?1",
[key_created],
)
.unwrap();
conn.execute(
"UPDATE repository_secret_audit_events SET created_at=?1",
[key_created],
)
.unwrap();
conn.execute(
"UPDATE repository_ssh_credentials SET created_at=?1",
[key_created],
)
.unwrap();
conn.execute(
"UPDATE repository_ssh_credential_revisions SET created_at=?1",
[key_created],
)
.unwrap();
conn.execute(
"UPDATE server_secret_versions SET created_at=?1",
[key_created],
)
.unwrap();
conn.execute("INSERT INTO repository_secret_operations VALUES('space','create-host','intent','host_trust','host',3,?1)", [key_created]).unwrap();
conn.execute("INSERT INTO repository_secret_audit_events VALUES('space','host-audit','host_trust_created','host',3,'actor',?1)", [key_created]).unwrap();
conn.execute("INSERT INTO repository_ssh_host_trusts VALUES('space','host','example.test',22,'ssh-ed25519','host-key','host-fingerprint',3,?1,?1)", [key_created]).unwrap();
conn.execute("INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',3,'example.test',22,'ssh-ed25519','host-key','host-fingerprint',?1)", [key_created]).unwrap();
conn.execute("UPDATE workdir_create_operations SET state='failed',failure='runtime_workdir_create_failed',host_trust_id='host',host_trust_revision=3,repository_access_mode='read_only',updated_at=?1", [workdir_updated]).unwrap();
}
#[test]
fn repository_key_cutover_compares_mixed_timestamp_formats_as_instants() {
for (key_created, workdir_updated) in [
("2026-01-01T00:00:00.000Z", "1767225600000"),
("2026-01-01T00:00:00.000Z", "1767225600001"),
("2026-01-01T09:00:00.000+09:00", "2026-01-01T00:00:00Z"),
(
"2026-01-01T00:00:00.000000001Z",
"2026-01-01T00:00:00.000000002Z",
),
] {
let dir = tempfile::tempdir().unwrap();
let conn = legacy_repository_keys(&dir.path().join("server.db"));
std::fs::write(dir.path().join("repository-secrets.master-key"), [42; 32]).unwrap();
legacy_ssh_snapshot_times(&conn, key_created, workdir_updated);
migrate_repository_keys_v86_to_v87(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 87);
let snapshot: (String, String, String, String) = conn.query_row(
"SELECT state,credential_fingerprint,host_trust_fingerprint,updated_at FROM workdir_create_operations",
[], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?)),
).unwrap();
assert_eq!(
snapshot,
(
"failed".into(),
"fingerprint".into(),
"host-fingerprint".into(),
workdir_updated.into()
)
);
let retained: (String, String, String) = conn.query_row(
"SELECT c.credential_fingerprint,r.credential_fingerprint,r.credential_operation_id
FROM workdir_create_credential_candidates c JOIN workdir_create_credential_retentions r
ON r.workspace_id=c.workspace_id AND r.operation_id=c.operation_id AND r.ordinal=c.ordinal",
[], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?)),
).unwrap();
assert_eq!(
retained,
(
"fingerprint".into(),
"fingerprint".into(),
"create-key".into()
)
);
assert_eq!(
conn.query_row(
"SELECT created_at FROM repository_ssh_credential_keys",
[],
|r| r.get::<_, String>(0)
)
.unwrap(),
key_created
);
assert!(
!conn
.prepare("PRAGMA foreign_key_check")
.unwrap()
.exists([])
.unwrap()
);
}
}
fn assert_timestamp_cutover_rolls_back(conn: &Connection, expected_context: &str) {
let before =
frozen_repository_json_rows(conn, "SELECT * FROM workdir_create_operations", &[]).unwrap();
let ciphertext: Vec<u8> = conn
.query_row(
"SELECT ciphertext FROM server_secret_versions WHERE purpose='private_key'",
[],
|r| r.get(0),
)
.unwrap();
let error = migrate_repository_keys_v86_to_v87(conn)
.unwrap_err()
.to_string();
assert!(error.contains(expected_context), "{error}");
assert!(error.contains("timestamp"), "{error}");
assert_eq!(current_schema_version(conn).unwrap(), 86);
assert_eq!(
frozen_repository_json_rows(conn, "SELECT * FROM workdir_create_operations", &[]).unwrap(),
before
);
assert_eq!(
conn.query_row(
"SELECT ciphertext FROM server_secret_versions WHERE purpose='private_key'",
[],
|r| r.get::<_, Vec<u8>>(0)
)
.unwrap(),
ciphertext
);
assert!(table_exists(conn, "repository_ssh_credential_revisions").unwrap());
assert!(!table_exists(conn, "repository_ssh_credential_keys").unwrap());
assert_eq!(
conn.query_row(
"SELECT count(*) FROM workdir_create_credential_candidates",
[],
|r| r.get::<_, i64>(0)
)
.unwrap(),
1
);
assert_eq!(
conn.query_row(
"SELECT count(*) FROM workdir_create_credential_revision_retentions",
[],
|r| r.get::<_, i64>(0)
)
.unwrap(),
1
);
assert_eq!(conn.query_row("SELECT count(*) FROM sqlite_temp_master WHERE name IN ('repository_key_cutover','repository_cutover_times','workdir_terminal_archive_cutover')", [], |r| r.get::<_,i64>(0)).unwrap(), 0);
assert_eq!(
conn.query_row("PRAGMA foreign_keys", [], |r| r.get::<_, i64>(0))
.unwrap(),
1
);
assert_eq!(
conn.query_row("PRAGMA legacy_alter_table", [], |r| r.get::<_, i64>(0))
.unwrap(),
0
);
}
#[test]
fn repository_key_cutover_rejects_newer_reused_snapshot_keys_without_committing_authority() {
for source in ["credential", "host_trust", "candidate"] {
for later in ["2026-01-01T00:00:00.001Z", "2026-01-01T00:00:00.000000001Z"] {
let dir = tempfile::tempdir().unwrap();
let conn = legacy_repository_keys(&dir.path().join("server.db"));
std::fs::write(dir.path().join("repository-secrets.master-key"), [42; 32]).unwrap();
legacy_ssh_snapshot_times(&conn, "2026-01-01T00:00:00.000Z", "1767225600000");
if source == "host_trust" {
conn.execute(
"UPDATE repository_ssh_host_trust_revisions SET created_at=?1",
[later],
)
.unwrap();
conn.execute("UPDATE repository_secret_operations SET created_at=?1 WHERE resource_kind='host_trust'", [later]).unwrap();
conn.execute("UPDATE repository_secret_audit_events SET created_at=?1 WHERE kind='host_trust_created'", [later]).unwrap();
} else {
conn.execute(
"UPDATE repository_ssh_credential_revisions SET created_at=?1",
[later],
)
.unwrap();
conn.execute("UPDATE repository_secret_operations SET created_at=?1 WHERE resource_kind='credential'", [later]).unwrap();
conn.execute("UPDATE repository_secret_audit_events SET created_at=?1 WHERE kind='credential_created'", [later]).unwrap();
if source == "candidate" {
conn.execute("UPDATE workdir_create_operations SET credential_id=NULL,credential_revision=NULL", []).unwrap();
}
}
assert_timestamp_cutover_rolls_back(&conn, "Workdir SSH snapshot");
}
}
}
#[test]
fn repository_key_cutover_invalid_authority_timestamps_roll_back_with_source_context() {
for (source, invalid) in [
("workdir_create_operations.updated_at", "not-a-date"),
(
"workdir_create_operations.updated_at",
"9223372036854775807",
),
(
"repository_ssh_credential_revisions.created_at",
"not-a-date",
),
(
"repository_ssh_host_trust_revisions.created_at",
"not-a-date",
),
("repository_secret_operations.created_at", "not-a-date"),
] {
let dir = tempfile::tempdir().unwrap();
let conn = legacy_repository_keys(&dir.path().join("server.db"));
std::fs::write(dir.path().join("repository-secrets.master-key"), [42; 32]).unwrap();
legacy_ssh_snapshot_times(&conn, "2026-01-01T00:00:00.000Z", "1767225600000");
match source {
"workdir_create_operations.updated_at" => {
conn.execute(
"UPDATE workdir_create_operations SET updated_at=?1",
[invalid],
)
.unwrap();
}
"repository_ssh_credential_revisions.created_at" => {
conn.execute(
"UPDATE repository_ssh_credential_revisions SET created_at=?1",
[invalid],
)
.unwrap();
conn.execute("UPDATE repository_secret_operations SET created_at=?1 WHERE resource_kind='credential'", [invalid]).unwrap();
}
"repository_ssh_host_trust_revisions.created_at" => {
conn.execute(
"UPDATE repository_ssh_host_trust_revisions SET created_at=?1",
[invalid],
)
.unwrap();
conn.execute("UPDATE repository_secret_operations SET created_at=?1 WHERE resource_kind='host_trust'", [invalid]).unwrap();
}
"repository_secret_operations.created_at" => {
conn.execute("INSERT INTO repository_secret_operations VALUES('space','old-delete','intent','credential','gone',1,?1)", [invalid]).unwrap();
conn.execute("INSERT INTO repository_secret_audit_events VALUES('space','old-delete-audit','credential_deleted','gone',1,'actor',?1)", [invalid]).unwrap();
}
_ => unreachable!(),
}
assert_timestamp_cutover_rolls_back(&conn, source);
}
}
#[test]
fn repository_key_cutover_rejects_orphan_candidates_without_dropping_source_evidence() {
let dir = tempfile::tempdir().unwrap();
let conn = legacy_repository_keys(&dir.path().join("server.db"));
std::fs::write(dir.path().join("repository-secrets.master-key"), [42; 32]).unwrap();
conn.execute_batch("PRAGMA foreign_keys=OFF;
INSERT INTO workdir_create_credential_candidates VALUES('space','missing-parent',0,'primary','key',7);
PRAGMA foreign_keys=ON;").unwrap();
let before = frozen_repository_json_rows(
&conn,
"SELECT * FROM workdir_create_credential_candidates ORDER BY operation_id,ordinal",
&[],
)
.unwrap();
let error = migrate_repository_keys_v86_to_v87(&conn).unwrap_err();
assert!(
error
.to_string()
.contains("credential candidates without a Workdir create operation"),
"{error}"
);
assert_eq!(current_schema_version(&conn).unwrap(), 86);
assert_eq!(
frozen_repository_json_rows(
&conn,
"SELECT * FROM workdir_create_credential_candidates ORDER BY operation_id,ordinal",
&[]
)
.unwrap(),
before
);
assert!(table_exists(&conn, "repository_ssh_credential_revisions").unwrap());
assert!(!table_exists(&conn, "repository_ssh_credential_keys").unwrap());
assert_eq!(
conn.query_row("PRAGMA foreign_keys", [], |r| r.get::<_, i64>(0))
.unwrap(),
1
);
}
#[test]
fn repository_key_cutover_reseals_secrets_and_preserves_retention_and_audit() {
let dir = tempfile::tempdir().unwrap();
@@ -150,12 +439,12 @@ fn repository_key_cutover_reseals_secrets_and_preserves_retention_and_audit() {
.unwrap();
assert!(
conn.execute(
"UPDATE workdir_create_operations SET updated_at='later'",
"UPDATE workdir_create_operations SET updated_at='2026-01-04T00:00:00.000Z'",
[]
)
.is_err()
);
assert!(conn.execute("INSERT INTO workdir_create_operations(workspace_id,operation_id,request_fingerprint,repository_id,resolved_runtime_id,config_projection_digest,working_directory_id,state,created_at,updated_at) VALUES('space','blocked','intent','repo','runtime','projection','other-workdir','pending','created','created')", []).is_err());
assert!(conn.execute("INSERT INTO workdir_create_operations(workspace_id,operation_id,request_fingerprint,repository_id,resolved_runtime_id,config_projection_digest,working_directory_id,state,created_at,updated_at) VALUES('space','blocked','intent','repo','runtime','projection','other-workdir','pending','2026-01-01T00:00:00.000Z','2026-01-01T00:00:00.000Z')", []).is_err());
assert!(
!conn
.prepare("PRAGMA foreign_key_check")
@@ -274,14 +563,14 @@ fn repository_key_cutover_preserves_host_endpoints_deleted_audit_and_nondefault_
let dir = tempfile::tempdir().unwrap();
let conn = legacy_repository_keys(&dir.path().join("server.db"));
std::fs::write(dir.path().join("repository-secrets.master-key"), [42; 32]).unwrap();
conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','host-create','intent','host_trust','host',3,'created');
INSERT INTO repository_secret_operations VALUES('space','host-move','intent','host_trust','host',4,'updated');
INSERT INTO repository_ssh_host_trusts VALUES('space','host','new.example.test',2222,'ssh-ed25519','host-key','host-fingerprint',4,'created','updated');
INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',3,'old.example.test',22,'ssh-ed25519','host-key','host-fingerprint','created');
INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',4,'new.example.test',2222,'ssh-ed25519','host-key','host-fingerprint','updated');
INSERT INTO repository_secret_audit_events VALUES('space','host-audit','host_trust_rotated','host',4,'host-actor','updated');
INSERT INTO repository_secret_operations VALUES('space','delete-gone','intent','credential','gone',5,'deleted');
INSERT INTO repository_secret_audit_events VALUES('space','delete-audit','credential_deleted','gone',5,'delete-actor','deleted');
conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','host-create','intent','host_trust','host',3,'2026-01-01T00:00:00.000Z');
INSERT INTO repository_secret_operations VALUES('space','host-move','intent','host_trust','host',4,'2026-01-02T00:00:00.000Z');
INSERT INTO repository_ssh_host_trusts VALUES('space','host','new.example.test',2222,'ssh-ed25519','host-key','host-fingerprint',4,'2026-01-01T00:00:00.000Z','2026-01-02T00:00:00.000Z');
INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',3,'old.example.test',22,'ssh-ed25519','host-key','host-fingerprint','2026-01-01T00:00:00.000Z');
INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',4,'new.example.test',2222,'ssh-ed25519','host-key','host-fingerprint','2026-01-02T00:00:00.000Z');
INSERT INTO repository_secret_audit_events VALUES('space','host-audit','host_trust_rotated','host',4,'host-actor','2026-01-02T00:00:00.000Z');
INSERT INTO repository_secret_operations VALUES('space','delete-gone','intent','credential','gone',5,'2026-01-03T00:00:00.000Z');
INSERT INTO repository_secret_audit_events VALUES('space','delete-audit','credential_deleted','gone',5,'delete-actor','2026-01-03T00:00:00.000Z');
UPDATE workdir_create_operations SET host_trust_id='host',host_trust_revision=3;").unwrap();
conn.execute_batch(&format!(
"PRAGMA foreign_keys={foreign_keys}; PRAGMA legacy_alter_table={legacy_alter};"
@@ -342,8 +631,8 @@ fn deleted_successful_workdir_archives_verbatim_history_without_authorizing_retr
let digest = "sha256:db15419814117c33c089a36c5348bda85d01a53fccd8d4d427685daf5983353b";
conn.execute("UPDATE workdir_create_operations SET state='succeeded',request_fingerprint=?1,selector='main',source_kind='local_path',source_uri='/tmp/repo',source_revision=9,source_fingerprint='source'",[digest]).unwrap();
conn.execute_batch("DELETE FROM workdir_create_credential_revision_retentions;
INSERT INTO repository_secret_operations VALUES('space','delete-key','delete-intent','credential','key',7,'deleted');
INSERT INTO repository_secret_audit_events VALUES('space','delete-audit','credential_deleted','key',7,'deleting-actor','deleted');
INSERT INTO repository_secret_operations VALUES('space','delete-key','delete-intent','credential','key',7,'2026-01-03T00:00:00.000Z');
INSERT INTO repository_secret_audit_events VALUES('space','delete-audit','credential_deleted','key',7,'deleting-actor','2026-01-03T00:00:00.000Z');
DELETE FROM repository_ssh_credentials;").unwrap();
let original =
frozen_repository_json_rows(&conn, "SELECT * FROM workdir_create_operations", &[])
@@ -666,7 +955,7 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei
"credential",
"key",
1,
"2026-01-01",
"2026-01-01T00:00:00.000Z",
"credential_created",
),
(
@@ -675,7 +964,7 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei
"credential",
"key",
2,
"2026-01-02",
"2026-01-02T00:00:00.000Z",
"credential_rotated",
),
(
@@ -684,7 +973,7 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei
"host_trust",
"host",
1,
"2026-01-01",
"2026-01-01T00:00:00.000Z",
"host_trust_created",
),
(
@@ -693,7 +982,7 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei
"host_trust",
"host",
2,
"2026-01-02",
"2026-01-02T00:00:00.000Z",
"host_trust_rotated",
),
] {
@@ -708,11 +997,11 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei
)
.unwrap();
}
conn.execute("INSERT INTO repository_ssh_credentials VALUES('space','key','Key','ssh-ed25519',?1,2,'active','2026-01-01','2026-01-02')",[&next_fp]).unwrap();
conn.execute("INSERT INTO repository_ssh_credentials VALUES('space','key','Key','ssh-ed25519',?1,2,'active','2026-01-01T00:00:00.000Z','2026-01-02T00:00:00.000Z')",[&next_fp]).unwrap();
let encryption_key = LessSafeKey::new(UnboundKey::new(&AES_256_GCM, &[42; 32]).unwrap());
for (legacy_counter, fingerprint, private, timestamp) in [
(1u64, &old_fp, &old_private, "2026-01-01"),
(2, &next_fp, &next_private, "2026-01-02"),
(1u64, &old_fp, &old_private, "2026-01-01T00:00:00.000Z"),
(2, &next_fp, &next_private, "2026-01-02T00:00:00.000Z"),
] {
conn.execute("INSERT INTO repository_ssh_credential_revisions VALUES('space','key',?1,'ssh-ed25519',?2,?3)",params![legacy_counter,fingerprint,timestamp]).unwrap();
let nonce = [legacy_counter as u8; 12];
@@ -727,10 +1016,10 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei
.unwrap();
conn.execute("INSERT INTO server_secret_versions VALUES('space','key',?1,'private_key','aes-256-gcm-v1',?2,?3,?4)",params![legacy_counter,nonce.as_slice(),ciphertext,timestamp]).unwrap();
}
conn.execute("INSERT INTO repository_ssh_host_trusts VALUES('space','host','new.example.test',22,'ssh-ed25519',?1,?2,2,'2026-01-01','2026-01-02')",params![public,old_fp]).unwrap();
conn.execute("INSERT INTO repository_ssh_host_trusts VALUES('space','host','new.example.test',22,'ssh-ed25519',?1,?2,2,'2026-01-01T00:00:00.000Z','2026-01-02T00:00:00.000Z')",params![public,old_fp]).unwrap();
for (legacy_counter, hostname, timestamp) in [
(1, "old.example.test", "2026-01-01"),
(2, "new.example.test", "2026-01-02"),
(1, "old.example.test", "2026-01-01T00:00:00.000Z"),
(2, "new.example.test", "2026-01-02T00:00:00.000Z"),
] {
conn.execute("INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',?1,?2,22,'ssh-ed25519',?3,?4,?5)",params![legacy_counter,hostname,public,old_fp,timestamp]).unwrap();
}
@@ -884,8 +1173,8 @@ fn deleted_legacy_receipt_does_not_bind_to_later_reused_key_ordinal() {
// The old deleted ID has been recreated using the same ordinal. Its earlier
// delete receipt must remain history, not acquire proof from this new key.
conn.execute_batch("DELETE FROM workdir_create_operations;
INSERT INTO repository_secret_operations VALUES('space','old-delete','intent','credential','key',7,'2025-01-01');
INSERT INTO repository_secret_audit_events VALUES('space','old-delete-audit','credential_deleted','key',7,'actor','2025-01-01');").unwrap();
INSERT INTO repository_secret_operations VALUES('space','old-delete','intent','credential','key',7,'2025-01-01T00:00:00.000Z');
INSERT INTO repository_secret_audit_events VALUES('space','old-delete-audit','credential_deleted','key',7,'actor','2025-01-01T00:00:00.000Z');").unwrap();
migrate_repository_keys_v86_to_v87(&conn).unwrap();
// The service consumes current receipt columns; unrelated schemas are not this fixture.
rename_saved_secret_receipt_counters(&conn).unwrap();
@@ -966,8 +1255,8 @@ fn repository_key_cutover_failure_preserves_schema_ciphertext_and_connection_pra
}
"inconsistent_retention" => {
// A different valid retained key passes the ordinal-only candidate FK.
conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','another-key','intent','credential','key',8,'later');
INSERT INTO repository_ssh_credential_revisions VALUES('space','key',8,'ssh-ed25519','other','later');
conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','another-key','intent','credential','key',8,'2026-01-04T00:00:00.000Z');
INSERT INTO repository_ssh_credential_revisions VALUES('space','key',8,'ssh-ed25519','other','2026-01-04T00:00:00.000Z');
UPDATE workdir_create_credential_revision_retentions SET credential_revision=8;").unwrap();
}
"missing_audit_receipt" => {
@@ -978,12 +1267,12 @@ fn repository_key_cutover_failure_preserves_schema_ciphertext_and_connection_pra
.unwrap();
}
"missing_host_receipt" => {
conn.execute_batch("INSERT INTO repository_ssh_host_trusts VALUES('space','host','example.test',22,'ssh-ed25519','key','host-fingerprint',4,'created','created');
INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',4,'example.test',22,'ssh-ed25519','key','host-fingerprint','created');").unwrap();
conn.execute_batch("INSERT INTO repository_ssh_host_trusts VALUES('space','host','example.test',22,'ssh-ed25519','key','host-fingerprint',4,'2026-01-01T00:00:00.000Z','2026-01-01T00:00:00.000Z');
INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',4,'example.test',22,'ssh-ed25519','key','host-fingerprint','2026-01-01T00:00:00.000Z');").unwrap();
}
"foreign_key_violation" => {
conn.execute_batch("PRAGMA foreign_keys=OFF;
INSERT INTO repository_secret_operations VALUES('missing-space','orphan','intent','credential','deleted',1,'created');
INSERT INTO repository_secret_operations VALUES('missing-space','orphan','intent','credential','deleted',1,'2026-01-01T00:00:00.000Z');
PRAGMA foreign_keys=ON;").unwrap();
}
_ => {}
@@ -817,17 +817,12 @@ mod current_authority {
fn binding_store() -> (SqliteWorkspaceStore, WorkspaceRuntimeBinding) {
let store = SqliteWorkspaceStore::in_memory().unwrap();
let workspace_key =
worker_runtime::auth::RuntimeIdentityMaterial::generate("workspace-key").unwrap();
let runtime_key =
worker_runtime::auth::RuntimeIdentityMaterial::generate("runtime-key").unwrap();
let workspace_fingerprint = normalize_runtime_public_key(&workspace_key.public_key)
.unwrap()
.1;
store.with_conn(|conn| {
conn.execute_batch("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) VALUES('owner','user','owner','Owner','created','updated');
INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) VALUES('space','owner','Space','active','created','updated');")?;
conn.execute("INSERT INTO workspace_signing_identities(workspace_id,key_id,algorithm,public_key,public_key_fingerprint,private_material_ref,state,created_at,provisioned_at,updated_at) VALUES('space','workspace-key','ed25519',?1,?2,'material','active','created','provisioned','updated')", params![workspace_key.public_key,workspace_fingerprint])?;
INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) VALUES('space','owner','Space','active','created','updated');
INSERT INTO workspace_signing_identities(workspace_id,key_id,algorithm,public_key,public_key_fingerprint,private_material_ref,state,created_at,provisioned_at,updated_at) VALUES('space','workspace-key','ed25519','public','sha256:workspace','material','active','created','provisioned','updated');")?;
Ok(())
}).unwrap();
let request = WorkspaceRuntimeBinding {
@@ -838,11 +833,7 @@ mod current_authority {
public_key: runtime_key.public_key,
public_key_fingerprint: String::new(),
binding_id: String::new(),
state: WorkspaceRuntimeBindingState::Configured,
authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity,
workspace_key_id: Some("workspace-key".into()),
workspace_public_key_fingerprint: Some(workspace_fingerprint),
workspace_trust_id: Some("runtime-issued-trust".into()),
created_at: "created".into(),
updated_at: "updated".into(),
revoked_at: None,
@@ -853,36 +844,10 @@ mod current_authority {
(store, binding)
}
fn evidence(binding: &WorkspaceRuntimeBinding) -> WorkspaceRuntimeVerificationEvidence {
WorkspaceRuntimeVerificationEvidence {
workspace_id: binding.workspace_id.clone(),
runtime_id: binding.runtime_id.clone(),
binding_id: binding.binding_id.clone(),
workspace_key_id: binding.workspace_key_id.clone().unwrap(),
workspace_public_key_fingerprint: binding
.workspace_public_key_fingerprint
.clone()
.unwrap(),
workspace_trust_id: binding.workspace_trust_id.clone().unwrap(),
runtime_public_key_fingerprint: binding.public_key_fingerprint.clone(),
challenge_id: "challenge".into(),
state: "verified".into(),
last_outcome: "verified".into(),
verified_at: Some("verified-at".into()),
checked_at: "checked-at".into(),
}
}
#[test]
fn same_key_reactivation_fences_old_cas_and_verification_evidence() {
fn same_key_reactivation_fences_old_config_and_admin_mutations() {
let (store, binding) = binding_store();
let old_proof = evidence(&binding);
store
.record_workspace_runtime_verification_attempt(&old_proof)
.unwrap();
store
.complete_workspace_runtime_verification(&old_proof)
.unwrap();
assert!(store.workspace_runtime_binding_matches(&binding).unwrap());
let (_, revoked) = store
.revoke_workspace_runtime_binding_key(
"space",
@@ -893,16 +858,22 @@ mod current_authority {
)
.unwrap();
assert_ne!(binding.binding_id, revoked.binding_id);
let mut replacement = binding.clone();
replacement.state = WorkspaceRuntimeBindingState::Configured;
assert!(!store.workspace_runtime_binding_matches(&binding).unwrap());
assert!(!store.workspace_runtime_binding_matches(&revoked).unwrap());
let (_, reactivated) = store
.put_workspace_runtime_binding_key(replacement, Some(&revoked.binding_id), "owner")
.put_workspace_runtime_binding_key(binding.clone(), Some(&revoked.binding_id), "owner")
.unwrap();
assert_eq!(
binding.public_key_fingerprint,
reactivated.public_key_fingerprint
);
assert_ne!(binding.binding_id, reactivated.binding_id);
assert!(
store
.workspace_runtime_binding_matches(&reactivated)
.unwrap()
);
assert!(!store.workspace_runtime_binding_matches(&binding).unwrap());
assert!(matches!(
store.put_workspace_runtime_binding_key(
reactivated.clone(),
@@ -921,42 +892,27 @@ mod current_authority {
),
Err(Error::RuntimeBindingIdConflict { .. })
));
assert!(
store
.record_workspace_runtime_verification_attempt(&old_proof)
.is_err()
);
assert!(
store
.complete_workspace_runtime_verification(&old_proof)
.is_err()
);
assert!(
!store
.workspace_runtime_verification_matches(
&reactivated,
&old_proof.workspace_public_key_fingerprint,
&old_proof.workspace_trust_id
)
.unwrap()
);
}
#[test]
fn endpoint_aba_and_workspace_key_change_cannot_reuse_verification_authority() {
fn endpoint_aba_fences_old_config_without_copying_workspace_signing_identity() {
let (store, binding) = binding_store();
let proof = evidence(&binding);
store
.record_workspace_runtime_verification_attempt(&proof)
.unwrap();
store
.complete_workspace_runtime_verification(&proof)
let renamed = store
.update_workspace_runtime_binding_metadata(
"space",
"remote",
"Renamed",
&binding.base_url,
"renamed-at",
)
.unwrap();
assert_eq!(binding.binding_id, renamed.binding_id);
assert!(!store.workspace_runtime_binding_matches(&binding).unwrap());
let moved = store
.update_workspace_runtime_binding_metadata(
"space",
"remote",
"Remote",
"Renamed",
"https://other.test",
"changed",
)
@@ -965,35 +921,166 @@ mod current_authority {
.update_workspace_runtime_binding_metadata(
"space",
"remote",
"Remote",
"Renamed",
"https://remote.test",
"changed-back",
)
.unwrap();
assert_ne!(moved.binding_id, binding.binding_id);
assert_ne!(returned.binding_id, binding.binding_id);
assert!(
store
.record_workspace_runtime_verification_attempt(&proof)
.is_err()
);
let current = evidence(&returned);
store
.record_workspace_runtime_verification_attempt(&current)
.unwrap();
store.with_conn(|conn| { conn.execute("UPDATE workspace_signing_identities SET public_key_fingerprint='sha256:replacement' WHERE workspace_id='space'",[])?; Ok(()) }).unwrap();
assert!(
store
.complete_workspace_runtime_verification(&current)
.is_err()
);
assert!(!store.workspace_runtime_binding_matches(&renamed).unwrap());
store.with_conn(|conn| { conn.execute("UPDATE workspace_signing_identities SET public_key_fingerprint='sha256:replacement' WHERE workspace_id='space'", [])?; Ok(()) }).unwrap();
assert_eq!(
store
.get_workspace_runtime_binding("space", "remote")
.unwrap(),
Some(returned.clone())
);
assert!(store.workspace_runtime_binding_matches(&returned).unwrap());
}
fn rows(conn: &Connection, sql: &str) -> Vec<Vec<rusqlite::types::Value>> {
let mut stmt = conn.prepare(sql).unwrap();
let count = stmt.column_count();
stmt.query_map([], |row| (0..count).map(|i| row.get(i)).collect())
.unwrap()
.collect::<rusqlite::Result<Vec<_>>>()
.unwrap()
}
fn schema89_bindings() -> Connection {
let conn = legacy_binding_database();
migrate_runtime_bindings_v87_to_v88(&conn).unwrap();
conn.execute(
"INSERT INTO __yoi_schema_migrations VALUES(89,'descriptive legacy evidence columns')",
[],
)
.unwrap();
conn.execute_batch("INSERT INTO workspace_runtime_bindings VALUES
('space','configured-trust','Configured','https://configured.test','key-c','fp-c','binding-c','configured','workspace_identity','workspace-key','sha256:workspace','trust-c','created-c','updated-c',NULL),
('space','verified','Verified','https://verified.test','key-v','fp-v','binding-v','verified','workspace_identity','workspace-key','sha256:workspace','trust-v','created-v','updated-v',NULL),
('space','revoked-null-trust','Revoked','https://revoked.test','key-r','fp-r','binding-r','revoked','workspace_identity','workspace-key',NULL,NULL,'created-r','updated-r','revoked-r'),
('space','legacy-verified','Legacy','https://legacy.test','key-l','fp-l','binding-l','verified','legacy_server_issuer',NULL,NULL,NULL,'created-l','updated-l',NULL),
('space','legacy-revoked','Legacy revoked','https://legacy-r.test','key-lr','fp-lr','binding-lr','revoked','legacy_server_issuer',NULL,NULL,NULL,'created-lr','updated-lr','revoked-lr');
INSERT INTO workspace_runtime_verifications VALUES
('space','verified','binding-v','workspace-key','sha256:workspace','trust-v','fp-v','challenge-v','verified','verified','verified-at','checked-v'),
('space','configured-trust','binding-c','workspace-key','sha256:workspace','trust-c','fp-c','challenge-c','pending','challenge_issued',NULL,'checked-c'),
('space','revoked-null-trust','binding-r','workspace-key','sha256:workspace','old-trust','fp-r','challenge-r','failed','verification_failed',NULL,'checked-r');").unwrap();
conn
}
#[test]
fn schema90_preserves_connections_and_archives_all_auth_metadata_without_reenrollment() {
let conn = schema89_bindings();
let connections = rows(
&conn,
"SELECT workspace_id,runtime_id,display_name,base_url,public_key,public_key_fingerprint,binding_id,authentication_mode,created_at,updated_at,revoked_at FROM workspace_runtime_bindings ORDER BY runtime_id",
);
let metadata = rows(
&conn,
"SELECT workspace_id,runtime_id,binding_id,state,workspace_key_id,workspace_public_key_fingerprint,workspace_trust_id,updated_at FROM workspace_runtime_bindings ORDER BY runtime_id",
);
let proofs = rows(
&conn,
"SELECT * FROM workspace_runtime_verifications ORDER BY runtime_id",
);
let identities = rows(&conn, "SELECT * FROM workspace_signing_identities");
let audit = rows(&conn, "SELECT * FROM workspace_runtime_binding_audit");
let removal = rows(&conn, "SELECT * FROM runtime_removal_operations");
migrate_runtime_binding_connections_v89_to_v90(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 90);
assert_eq!(
rows(
&conn,
"SELECT * FROM workspace_runtime_bindings ORDER BY runtime_id"
),
connections
);
assert_eq!(
rows(
&conn,
"SELECT * FROM workspace_runtime_binding_verification_archive ORDER BY runtime_id"
),
metadata
);
assert_eq!(
rows(
&conn,
"SELECT * FROM workspace_runtime_verification_archive ORDER BY runtime_id"
),
proofs
);
assert_eq!(
rows(&conn, "SELECT * FROM workspace_signing_identities"),
identities
);
assert_eq!(
rows(&conn, "SELECT * FROM workspace_runtime_binding_audit"),
audit
);
assert_eq!(
rows(&conn, "SELECT * FROM runtime_removal_operations"),
removal
);
verify_runtime_binding_connection_schema(&conn).unwrap();
assert!(!table_exists(&conn, "workspace_runtime_verifications").unwrap());
assert!(
conn.query_row("PRAGMA foreign_keys", [], |r| r.get::<_, bool>(0))
.unwrap()
);
assert_eq!(
rows(&conn, "SELECT * FROM pragma_foreign_key_check"),
Vec::<Vec<rusqlite::types::Value>>::new()
);
conn.execute_batch("UPDATE workspace_runtime_bindings SET base_url='https://moved.test',binding_id='binding-new' WHERE runtime_id='verified';
UPDATE workspace_runtime_bindings SET revoked_at='admin-revoked' WHERE runtime_id='configured-trust';
DELETE FROM workspace_runtime_bindings WHERE runtime_id='legacy-revoked';").unwrap();
assert_eq!(
rows(
&conn,
"SELECT * FROM workspace_runtime_binding_verification_archive ORDER BY runtime_id"
),
metadata
);
assert_eq!(
rows(
&conn,
"SELECT * FROM workspace_runtime_verification_archive ORDER BY runtime_id"
),
proofs
);
conn.execute(
"UPDATE runtime_removal_operations SET state='cleanup_pending'",
[],
)
.unwrap();
assert!(conn.execute("UPDATE workspace_runtime_bindings SET display_name='Blocked' WHERE runtime_id='remote'", []).unwrap_err().to_string().contains("runtime_removal_in_progress"));
}
#[test]
fn schema90_failed_rebuild_rolls_back_connections_archives_and_schema_marker() {
let conn = schema89_bindings();
// An incompatible preexisting archive is not permission to discard historical evidence.
conn.execute_batch("CREATE TABLE workspace_runtime_verification_archive(existing TEXT);")
.unwrap();
let before = rows(
&conn,
"SELECT * FROM workspace_runtime_bindings ORDER BY runtime_id",
);
assert!(migrate_runtime_binding_connections_v89_to_v90(&conn).is_err());
assert_eq!(current_schema_version(&conn).unwrap(), 89);
assert_eq!(
rows(
&conn,
"SELECT * FROM workspace_runtime_bindings ORDER BY runtime_id"
),
before
);
assert!(!table_exists(&conn, "workspace_runtime_binding_verification_archive").unwrap());
assert!(table_exists(&conn, "workspace_runtime_verifications").unwrap());
assert!(
conn.query_row("PRAGMA foreign_keys", [], |r| r.get::<_, bool>(0))
.unwrap()
.state,
WorkspaceRuntimeBindingState::Configured
);
}
}
+27 -228
View File
@@ -2,9 +2,7 @@ use crate::Error;
use crate::resource_broker::BackendResourceBroker;
#[cfg(test)]
use crate::resource_broker::BackendResourceTarget;
use crate::store::{
ControlPlaneStore, WorkspaceRuntimeAuthenticationMode, WorkspaceRuntimeBindingState,
};
use crate::store::{ControlPlaneStore, WorkspaceRuntimeAuthenticationMode};
use crate::workspace_signing_identity::WorkspaceSigningIdentityService;
use chrono::Utc;
use protocol::Segment;
@@ -66,11 +64,7 @@ use worker_runtime::ssh_host_key_probe::{
SSH_HOST_KEY_PROBE_OPERATION, SSH_HOST_KEY_PROBE_PATH, SshHostKeyProbeRequest,
SshHostKeyProbeResponse,
};
use worker_runtime::workspace_issuer::{
WorkspaceCapabilityClaims, WorkspaceRuntimeVerificationAcknowledgement,
WorkspaceRuntimeVerificationChallenge, WorkspaceRuntimeVerificationReceipt,
WorkspaceRuntimeVerificationResponse, workspace_request_body_digest,
};
use worker_runtime::workspace_issuer::{WorkspaceCapabilityClaims, workspace_request_body_digest};
pub const EMBEDDED_RUNTIME_ID: &str = "embedded-worker-runtime";
const EMBEDDED_HOST_KIND: &str = "embedded-worker-runtime-host";
@@ -925,32 +919,6 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
})
}
fn activate_workspace_authorization(&self, _binding: crate::store::WorkspaceRuntimeBinding) {}
fn send_workspace_verification_challenge(
&self,
_challenge: &WorkspaceRuntimeVerificationChallenge,
_bearer_token: &str,
) -> Result<WorkspaceRuntimeVerificationResponse, RuntimePingFailure> {
Err(RuntimePingFailure::new(
RuntimePingFailureKind::Unsupported,
"runtime_workspace_verification_unsupported",
"Workspace Runtime verification is unavailable for this Runtime provider",
))
}
fn send_workspace_verification_acknowledgement(
&self,
_acknowledgement: &WorkspaceRuntimeVerificationAcknowledgement,
_bearer_token: &str,
) -> Result<WorkspaceRuntimeVerificationReceipt, RuntimePingFailure> {
Err(RuntimePingFailure::new(
RuntimePingFailureKind::Unsupported,
"runtime_workspace_verification_unsupported",
"Workspace Runtime verification is unavailable for this Runtime provider",
))
}
fn list_hosts(&self, limit: usize) -> RuntimeList<InternalHostSummary>;
fn list_workers(&self, limit: usize) -> RuntimeList<InternalWorkerSummary>;
@@ -2277,48 +2245,6 @@ impl RuntimeRegistry {
runtime.ping()
}
pub fn activate_workspace_authorization(
&self,
runtime_id: &str,
binding: crate::store::WorkspaceRuntimeBinding,
) -> Result<(), RuntimeRegistryError> {
self.runtime(runtime_id)?
.activate_workspace_authorization(binding);
Ok(())
}
pub fn send_workspace_verification_challenge(
&self,
runtime_id: &str,
challenge: &WorkspaceRuntimeVerificationChallenge,
bearer_token: &str,
) -> Result<WorkspaceRuntimeVerificationResponse, RuntimePingFailure> {
let runtime = self.runtime(runtime_id).map_err(|_| {
RuntimePingFailure::new(
RuntimePingFailureKind::Configuration,
"runtime_verification_registration_unavailable",
"Registered Runtime binding is unavailable",
)
})?;
runtime.send_workspace_verification_challenge(challenge, bearer_token)
}
pub fn send_workspace_verification_acknowledgement(
&self,
runtime_id: &str,
acknowledgement: &WorkspaceRuntimeVerificationAcknowledgement,
bearer_token: &str,
) -> Result<WorkspaceRuntimeVerificationReceipt, RuntimePingFailure> {
let runtime = self.runtime(runtime_id).map_err(|_| {
RuntimePingFailure::new(
RuntimePingFailureKind::Configuration,
"runtime_verification_registration_unavailable",
"Registered Runtime binding is unavailable",
)
})?;
runtime.send_workspace_verification_acknowledgement(acknowledgement, bearer_token)
}
fn runtimes_snapshot(&self) -> Vec<Arc<dyn WorkspaceWorkerRuntime>> {
self.runtimes
.read()
@@ -3518,7 +3444,9 @@ pub struct WorkspaceRuntimeAuthorization {
store: Arc<dyn ControlPlaneStore>,
signing_identities: WorkspaceSigningIdentityService,
backend_url: String,
binding: Arc<RwLock<Option<crate::store::WorkspaceRuntimeBinding>>>,
// The connection selected for this client. Compare it with the current
// administrative configuration before sending, never with a past auth result.
binding: crate::store::WorkspaceRuntimeBinding,
}
impl std::fmt::Debug for WorkspaceRuntimeAuthorization {
@@ -3535,19 +3463,13 @@ impl WorkspaceRuntimeAuthorization {
store: Arc<dyn ControlPlaneStore>,
signing_identities: WorkspaceSigningIdentityService,
backend_url: impl Into<String>,
binding: Option<crate::store::WorkspaceRuntimeBinding>,
binding: crate::store::WorkspaceRuntimeBinding,
) -> Self {
Self {
store,
signing_identities,
backend_url: backend_url.into(),
binding: Arc::new(RwLock::new(binding)),
}
}
fn activate(&self, binding: crate::store::WorkspaceRuntimeBinding) {
if let Ok(mut current) = self.binding.write() {
*current = Some(binding);
binding,
}
}
@@ -3559,30 +3481,14 @@ impl WorkspaceRuntimeAuthorization {
worker_id: Option<&str>,
body: &[u8],
) -> Result<String, RuntimeDiagnostic> {
let binding = self
.binding
.read()
.map_err(|_| {
diagnostic(
"workspace_runtime_authorization_unavailable",
HostDiagnosticSeverity::Error,
"Workspace Runtime authorization is unavailable".to_string(),
)
})?
.clone()
.ok_or_else(|| {
diagnostic(
"workspace_runtime_verification_required",
HostDiagnosticSeverity::Error,
"Workspace Runtime binding is not verified".to_string(),
)
})?;
if binding.state != WorkspaceRuntimeBindingState::Verified
|| binding.authentication_mode != WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity
let binding = &self.binding;
// A removed/replaced endpoint must not be used by an old client. This
// compares connection configuration, not cached authentication success.
if binding.authentication_mode != WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity
|| binding.revoked_at.is_some()
|| !self
.store
.workspace_runtime_binding_matches(&binding)
.workspace_runtime_binding_matches(binding)
.map_err(|error| {
diagnostic(
"workspace_runtime_authorization_unavailable",
@@ -3594,7 +3500,7 @@ impl WorkspaceRuntimeAuthorization {
return Err(diagnostic(
"workspace_runtime_authorization_stale",
HostDiagnosticSeverity::Error,
"Workspace Runtime binding changed or was revoked".to_string(),
"Workspace Runtime connection changed or was revoked".to_string(),
));
}
let identity = self
@@ -3607,63 +3513,26 @@ impl WorkspaceRuntimeAuthorization {
error.to_string(),
)
})?;
let workspace_key_id = binding.workspace_key_id.as_deref().ok_or_else(|| {
diagnostic(
"workspace_runtime_authorization_invalid",
HostDiagnosticSeverity::Error,
"Workspace Runtime binding is missing its Workspace key".to_string(),
)
})?;
let workspace_public_key_fingerprint = binding
.workspace_public_key_fingerprint
.as_deref()
.ok_or_else(|| {
diagnostic(
"workspace_runtime_authorization_invalid",
HostDiagnosticSeverity::Error,
"Workspace Runtime binding is missing its Workspace public key fingerprint"
.to_string(),
)
})?;
let workspace_trust_id = binding.workspace_trust_id.as_deref().ok_or_else(|| {
diagnostic(
"workspace_runtime_authorization_invalid",
HostDiagnosticSeverity::Error,
"Workspace Runtime binding is missing its Workspace trust ID".to_string(),
)
})?;
if identity.state != "active"
|| identity.key_id != workspace_key_id
|| identity.public_key_fingerprint.as_deref() != Some(workspace_public_key_fingerprint)
|| !self
.store
.workspace_runtime_verification_matches(
&binding,
workspace_public_key_fingerprint,
workspace_trust_id,
)
.map_err(|error| {
diagnostic(
"workspace_runtime_authorization_unavailable",
HostDiagnosticSeverity::Error,
error.to_string(),
)
})?
{
if identity.state != "active" {
return Err(diagnostic(
"workspace_runtime_authorization_stale",
"workspace_signing_identity_unavailable",
HostDiagnosticSeverity::Error,
"Workspace signing identity no longer matches the verified binding".to_string(),
"Workspace signing identity is not active".to_string(),
));
}
let fingerprint = identity.public_key_fingerprint.ok_or_else(|| {
diagnostic(
"workspace_signing_identity_unavailable",
HostDiagnosticSeverity::Error,
"Workspace signing identity has no public key fingerprint".to_string(),
)
})?;
let now = Utc::now().timestamp();
let claims = WorkspaceCapabilityClaims {
issuer: self.backend_url.clone(),
issuer_workspace_id: binding.workspace_id.clone(),
issuer_key_id: identity.key_id,
issuer_public_key_fingerprint: workspace_public_key_fingerprint.to_string(),
trust_id: workspace_trust_id.to_string(),
binding_id: binding.binding_id.clone(),
issuer_public_key_fingerprint: fingerprint,
runtime_id: binding.runtime_id.clone(),
worker_id: worker_id.map(str::to_string),
operation: operation.to_string(),
@@ -3674,6 +3543,8 @@ impl WorkspaceRuntimeAuthorization {
exp: now.saturating_add(60),
jti: uuid::Uuid::now_v7().to_string(),
};
// The signer validates that these claims still name the selected current
// identity; a concurrent rotation cannot silently sign mismatched claims.
self.signing_identities
.issue_workspace_capability(&binding.workspace_id, &claims)
.map_err(|error| {
@@ -4251,49 +4122,6 @@ impl RemoteWorkerRuntime {
format!("{base}/v1/workers/{worker_id}/protocol/ws")
}
fn post_bearer_json<T, U>(
&self,
path: &str,
body: &T,
bearer_token: &str,
) -> Result<U, RuntimePingFailure>
where
T: Serialize + ?Sized,
U: DeserializeOwned,
{
let response = self
.http
.post(self.endpoint(path))
.bearer_auth(bearer_token)
.json(body)
.send()
.map_err(|error| {
RuntimePingFailure::new(
RuntimePingFailureKind::NetworkUnreachable,
"runtime_workspace_verification_unreachable",
format!("Runtime verification request failed: {error}"),
)
})?;
let status = response.status();
if !status.is_success() {
return Err(RuntimePingFailure::new(
RuntimePingFailureKind::MalformedResponse,
"runtime_workspace_verification_rejected",
format!(
"Runtime verification request returned HTTP {}",
status.as_u16()
),
));
}
response.json::<U>().map_err(|error| {
RuntimePingFailure::new(
RuntimePingFailureKind::MalformedResponse,
"runtime_workspace_verification_invalid_response",
format!("Runtime verification response was invalid: {error}"),
)
})
}
fn get_json<T>(&self, path: &str) -> Result<T, RuntimeDiagnostic>
where
T: DeserializeOwned + Send + 'static,
@@ -4702,36 +4530,6 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
})
}
fn activate_workspace_authorization(&self, binding: crate::store::WorkspaceRuntimeBinding) {
if let Some(authorization) = &self.workspace_authorization {
authorization.activate(binding);
}
}
fn send_workspace_verification_challenge(
&self,
challenge: &WorkspaceRuntimeVerificationChallenge,
bearer_token: &str,
) -> Result<WorkspaceRuntimeVerificationResponse, RuntimePingFailure> {
self.post_bearer_json(
worker_runtime::workspace_issuer::WORKSPACE_VERIFICATION_CHALLENGE_PATH,
challenge,
bearer_token,
)
}
fn send_workspace_verification_acknowledgement(
&self,
acknowledgement: &WorkspaceRuntimeVerificationAcknowledgement,
bearer_token: &str,
) -> Result<WorkspaceRuntimeVerificationReceipt, RuntimePingFailure> {
self.post_bearer_json(
worker_runtime::workspace_issuer::WORKSPACE_VERIFICATION_ACK_PATH,
acknowledgement,
bearer_token,
)
}
fn list_hosts(&self, limit: usize) -> RuntimeList<InternalHostSummary> {
if limit == 0 {
return RuntimeList::new(Vec::new(), Vec::new());
@@ -6536,6 +6334,7 @@ pub fn placeholder_spawn_response(host_id: impl Into<String>) -> WorkerSpawnResu
#[cfg(test)]
mod tests {
mod restore_auth_tests;
mod workspace_auth_tests;
use super::*;
use serde_json::json;
@@ -9,36 +9,18 @@ use worker_runtime::{
auth::RuntimeIdentityMaterial,
http_server::{WorkspaceRuntimeHttpAuth, runtime_http_router_with_workspace_auth},
workspace_issuer::{
InMemoryWorkspaceClaimReplayProtection, InMemoryWorkspaceRuntimeVerificationAuthority,
RuntimeVerificationSigner, WorkspaceCapabilityVerifier, WorkspaceIssuerTrustRecord,
WorkspaceIssuerTrustState, WorkspaceRuntimeVerificationAuthority,
WorkspaceRuntimeVerificationRecord, issue_workspace_capability_token,
InMemoryWorkspaceClaimReplayProtection, WorkspaceCapabilityVerifier,
WorkspaceIssuerTrustRecord, WorkspaceIssuerTrustState, issue_workspace_capability_token,
},
};
#[tokio::test]
async fn restore_capabilities_authorize_runtime_routes_without_relaxing_request_binding() {
let workspace_identity = RuntimeIdentityMaterial::generate("workspace-key").unwrap();
let runtime_identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap();
let signer = RuntimeVerificationSigner::from_identity(&runtime_identity).unwrap();
let now = Utc::now().timestamp();
let workspace_public_key_fingerprint =
crate::workspace_signing_identity::public_key_fingerprint(&workspace_identity.public_key)
.unwrap();
let binding_id = "binding-restore-test".to_string();
let verifications = Arc::new(InMemoryWorkspaceRuntimeVerificationAuthority::default());
verifications
.record(WorkspaceRuntimeVerificationRecord {
workspace_id: "workspace-test".into(),
runtime_id: "runtime-test".into(),
binding_id: binding_id.clone(),
workspace_trust_id: "trust-restore-test".to_string(),
workspace_key_id: "workspace-key".into(),
workspace_public_key_fingerprint: workspace_public_key_fingerprint.clone(),
runtime_public_key_fingerprint: signer.public_key_fingerprint().to_owned(),
verified_at: now,
})
.unwrap();
let public_key =
worker_runtime::auth::decode_public_key(&workspace_identity.public_key).unwrap();
let verifier = WorkspaceCapabilityVerifier::new(
@@ -69,8 +51,7 @@ async fn restore_capabilities_authorize_runtime_routes_without_relaxing_request_
None,
WorkspaceRuntimeHttpAuth {
verifier,
signer,
verifications,
runtime_id: "runtime-test".into(),
},
);
let worker_id = EmbeddedWorkerId::from_legacy_u64(1).to_string();
@@ -93,8 +74,6 @@ async fn restore_capabilities_authorize_runtime_routes_without_relaxing_request_
"wrong-worker",
"wrong-body",
"wrong-key-fingerprint",
"wrong-binding-id",
"wrong-trust-id",
] {
let claims = WorkspaceCapabilityClaims {
issuer: "https://backend.test".into(),
@@ -111,16 +90,6 @@ async fn restore_capabilities_authorize_runtime_routes_without_relaxing_request_
} else {
workspace_public_key_fingerprint.clone()
},
binding_id: if case == "wrong-binding-id" {
"binding-retired".to_string()
} else {
binding_id.clone()
},
trust_id: if case == "wrong-trust-id" {
"trust-retired".to_string()
} else {
"trust-restore-test".to_string()
},
runtime_id: "runtime-test".into(),
worker_id: if case == "wrong-worker" {
Some(EmbeddedWorkerId::from_legacy_u64(2).to_string())
@@ -176,27 +145,16 @@ async fn restore_capabilities_authorize_runtime_routes_without_relaxing_request_
} else {
assert_eq!(
status,
if case == "wrong-binding-id" {
StatusCode::FORBIDDEN
} else {
StatusCode::UNAUTHORIZED
},
StatusCode::UNAUTHORIZED,
"{path} {case}: {}",
error.error.message
);
assert_eq!(
error.error.code,
if case == "wrong-binding-id" {
"workspace_runtime_verification_stale"
} else {
"unauthorized"
}
);
assert_eq!(error.error.code, "unauthorized");
let expected = match case {
"wrong-operation" => Some("does not authorize this operation"),
"wrong-worker" => Some("targets another Worker"),
"wrong-body" => Some("does not bind this request body"),
"wrong-key-fingerprint" | "wrong-binding-id" | "wrong-trust-id" => None,
"wrong-key-fingerprint" => None,
_ => unreachable!(),
};
if let Some(expected) = expected {
@@ -0,0 +1,316 @@
use super::*;
use crate::store::{
AccountRecord, SqliteWorkspaceStore, WorkspaceRecord, WorkspaceRuntimeBinding,
WorkspaceSigningIdentityRecord,
};
use crate::workspace_signing_identity::{
InMemoryWorkspaceSigningMaterialStore, WorkspaceSigningMaterialStore,
WorkspaceSigningPrivateMaterial, public_key_fingerprint,
};
use worker_runtime::auth::RuntimeIdentityMaterial;
use worker_runtime::workspace_issuer::{
InMemoryWorkspaceClaimReplayProtection, VerifiedWorkspaceCapability,
WorkspaceCapabilityExpectation, WorkspaceCapabilityVerificationError,
WorkspaceCapabilityVerifier, WorkspaceIssuerTrustRecord, WorkspaceIssuerTrustState,
};
const WORKSPACE: &str = "workspace-auth-test";
const RUNTIME: &str = "runtime-auth-test";
const BACKEND: &str = "https://backend.test";
const PATH: &str = "/v1/ping?probe=1";
struct Fixture {
_temp: tempfile::TempDir,
store: Arc<SqliteWorkspaceStore>,
materials: Arc<InMemoryWorkspaceSigningMaterialStore>,
service: WorkspaceSigningIdentityService,
authorizer: WorkspaceRuntimeAuthorization,
binding: WorkspaceRuntimeBinding,
identity: WorkspaceSigningIdentityRecord,
}
impl Fixture {
async fn new() -> Self {
let temp = tempfile::tempdir().unwrap();
let store = Arc::new(SqliteWorkspaceStore::open(temp.path().join("server.db")).unwrap());
store
.upsert_account(&AccountRecord {
account_id: "owner".into(),
kind: "user".into(),
handle: "owner".into(),
display_name: "Owner".into(),
created_at: "1".into(),
updated_at: "1".into(),
})
.unwrap();
store
.upsert_workspace(&WorkspaceRecord {
workspace_id: WORKSPACE.into(),
owner_account_id: "owner".into(),
display_name: "Workspace".into(),
state: "active".into(),
created_at: "1".into(),
updated_at: "1".into(),
})
.await
.unwrap();
let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default());
let service = WorkspaceSigningIdentityService::new(store.clone(), materials.clone());
let identity = service.provision_existing(WORKSPACE, "owner").unwrap();
let runtime_identity = RuntimeIdentityMaterial::generate(RUNTIME).unwrap();
store
.upsert_workspace_runtime_binding(
WorkspaceRuntimeBinding {
workspace_id: WORKSPACE.into(),
runtime_id: RUNTIME.into(),
display_name: "Runtime".into(),
base_url: "https://runtime.test".into(),
public_key_fingerprint: public_key_fingerprint(&runtime_identity.public_key)
.unwrap(),
public_key: runtime_identity.public_key,
binding_id: String::new(),
authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity,
created_at: "1".into(),
updated_at: "1".into(),
revoked_at: None,
},
false,
)
.unwrap();
let binding = store
.get_workspace_runtime_binding(WORKSPACE, RUNTIME)
.unwrap()
.unwrap();
let authorizer = WorkspaceRuntimeAuthorization::new(
store.clone(),
service.clone(),
BACKEND,
binding.clone(),
);
Self {
_temp: temp,
store,
materials,
service,
authorizer,
binding,
identity,
}
}
fn issue(&self) -> Result<String, RuntimeDiagnostic> {
self.authorizer
.issue("GET", PATH, RUNTIME_PING_PERMISSION, None, b"")
}
fn verifier(&self) -> WorkspaceCapabilityVerifier {
WorkspaceCapabilityVerifier::new(
vec![WorkspaceIssuerTrustRecord {
workspace_id: WORKSPACE.into(),
backend_url: BACKEND.into(),
key_id: self.identity.key_id.clone(),
algorithm: "ed25519".into(),
public_key: self.identity.public_key.clone().unwrap(),
public_key_fingerprint: self.identity.public_key_fingerprint.clone().unwrap(),
trust_id: uuid::Uuid::now_v7().to_string(),
state: WorkspaceIssuerTrustState::Active,
registered_at_unix: Utc::now().timestamp(),
updated_at_unix: Utc::now().timestamp(),
}],
Arc::new(InMemoryWorkspaceClaimReplayProtection::default()),
)
.unwrap()
}
// Fixture-only rotation: deliberately no production rotation API or live database.
fn rotate_identity(&mut self) {
let key_id = "WK-rotated";
let material_ref = format!("workspace-signing/{WORKSPACE}/{key_id}");
let material = WorkspaceSigningPrivateMaterial::generate(WORKSPACE, key_id).unwrap();
let public_key = material.validate_and_public_key(WORKSPACE, key_id).unwrap();
let fingerprint = public_key_fingerprint(&public_key).unwrap();
self.materials
.put_if_absent(&material_ref, &material)
.unwrap();
self.store.with_conn(|conn| {
conn.execute("UPDATE workspace_signing_identities SET key_id = ?1, public_key = ?2, public_key_fingerprint = ?3, private_material_ref = ?4 WHERE workspace_id = ?5",
rusqlite::params![key_id, public_key, fingerprint, material_ref, WORKSPACE])?;
Ok(())
}).unwrap();
self.identity = self.service.get_validated(WORKSPACE).unwrap();
}
}
fn verify(
verifier: &WorkspaceCapabilityVerifier,
token: &str,
) -> Result<VerifiedWorkspaceCapability, WorkspaceCapabilityVerificationError> {
verifier.verify(
token,
&WorkspaceCapabilityExpectation {
workspace_id: WORKSPACE,
runtime_id: RUNTIME,
worker_id: None,
operation: RUNTIME_PING_PERMISSION,
method: "GET",
path_and_query: PATH,
body_digest: &workspace_request_body_digest(b""),
now_unix: Utc::now().timestamp(),
},
)
}
#[tokio::test]
async fn authorizer_token_authenticates_actual_request_without_prior_verification_record() {
let fixture = Fixture::new().await;
let token = fixture.issue().unwrap();
let verifier = fixture.verifier();
let authenticated = verify(&verifier, &token).unwrap();
assert_eq!(authenticated.workspace_id, WORKSPACE);
assert_eq!(authenticated.issuer_key_id, fixture.identity.key_id);
assert_eq!(
verify(&verifier, &token).unwrap_err(),
WorkspaceCapabilityVerificationError::Replay
);
let token = fixture.issue().unwrap();
let wrong_request = WorkspaceCapabilityExpectation {
workspace_id: WORKSPACE,
runtime_id: RUNTIME,
worker_id: None,
operation: RUNTIME_PING_PERMISSION,
method: "GET",
path_and_query: "/v1/ping?probe=2",
body_digest: &workspace_request_body_digest(b""),
now_unix: Utc::now().timestamp(),
};
assert_eq!(
verifier.verify(&token, &wrong_request).unwrap_err(),
WorkspaceCapabilityVerificationError::WrongPathAndQuery
);
}
#[tokio::test]
async fn existing_authorizer_selects_rotated_workspace_key_and_new_runtime_trust() {
let mut fixture = Fixture::new().await;
let old_verifier = fixture.verifier();
let old_token = fixture.issue().unwrap();
fixture.rotate_identity();
let new_token = fixture.issue().unwrap();
let new_verifier = fixture.verifier();
assert_eq!(
verify(&new_verifier, &new_token).unwrap().issuer_key_id,
"WK-rotated"
);
assert_eq!(
verify(&new_verifier, &old_token).unwrap_err(),
WorkspaceCapabilityVerificationError::WrongIssuerKey
);
assert_eq!(
verify(&old_verifier, &new_token).unwrap_err(),
WorkspaceCapabilityVerificationError::WrongIssuerKey
);
// Changing Workspace authority did not replace or copy metadata into the connection.
assert_eq!(
fixture
.store
.get_workspace_runtime_binding(WORKSPACE, RUNTIME)
.unwrap()
.unwrap(),
fixture.binding
);
}
#[tokio::test]
async fn replaced_or_revoked_connection_rejects_stale_authorizer() {
for revoked in [false, true] {
let fixture = Fixture::new().await;
fixture.issue().unwrap();
if revoked {
fixture
.store
.revoke_workspace_runtime_binding(WORKSPACE, RUNTIME, "2")
.unwrap();
} else {
let mut replacement = fixture.binding.clone();
replacement.base_url = "https://replacement-runtime.test".into();
fixture
.store
.upsert_workspace_runtime_binding(replacement, true)
.unwrap();
}
assert_eq!(
fixture.issue().unwrap_err().code,
"workspace_runtime_authorization_stale"
);
}
}
#[tokio::test]
async fn missing_workspace_signing_material_rejects_authorization_without_fallback() {
let fixture = Fixture::new().await;
fixture
.materials
.delete(&fixture.identity.private_material_ref)
.unwrap();
let error = fixture.issue().unwrap_err();
assert_eq!(error.code, "workspace_runtime_authorization_unavailable");
assert!(
error.message.contains("private material is missing"),
"{}",
error.message
);
}
#[tokio::test]
async fn capability_claims_must_match_current_workspace_identity_before_signing() {
let mut fixture = Fixture::new().await;
let now = Utc::now().timestamp();
let mut claims = WorkspaceCapabilityClaims {
issuer: BACKEND.into(),
issuer_workspace_id: WORKSPACE.into(),
issuer_key_id: fixture.identity.key_id.clone(),
issuer_public_key_fingerprint: fixture.identity.public_key_fingerprint.clone().unwrap(),
runtime_id: RUNTIME.into(),
worker_id: None,
operation: RUNTIME_PING_PERMISSION.into(),
method: "GET".into(),
path_and_query: PATH.into(),
body_digest: workspace_request_body_digest(b""),
iat: now,
exp: now + 60,
jti: uuid::Uuid::now_v7().to_string(),
};
let token = fixture
.service
.issue_workspace_capability(WORKSPACE, &claims)
.unwrap();
verify(&fixture.verifier(), &token).unwrap();
for field in ["workspace", "key", "fingerprint"] {
let mut mismatched = claims.clone();
match field {
"workspace" => mismatched.issuer_workspace_id = "another-workspace".into(),
"key" => mismatched.issuer_key_id = "WK-another".into(),
"fingerprint" => {
mismatched.issuer_public_key_fingerprint = format!("sha256:{}", "0".repeat(64))
}
_ => unreachable!(),
}
assert!(
matches!(fixture.service.issue_workspace_capability(WORKSPACE, &mismatched),
Err(Error::WorkspaceSigningIdentity { ref code, .. }) if code == "workspace_capability_identity_mismatch"),
"{field}"
);
}
fixture.rotate_identity();
assert!(
matches!(fixture.service.issue_workspace_capability(WORKSPACE, &claims),
Err(Error::WorkspaceSigningIdentity { ref code, .. }) if code == "workspace_capability_identity_mismatch")
);
claims.issuer_key_id = fixture.identity.key_id.clone();
claims.issuer_public_key_fingerprint = fixture.identity.public_key_fingerprint.clone().unwrap();
let token = fixture
.service
.issue_workspace_capability(WORKSPACE, &claims)
.unwrap();
verify(&fixture.verifier(), &token).unwrap();
}
+22 -28
View File
@@ -442,49 +442,43 @@ CREATE TABLE workspace_runtime_bindings (
public_key TEXT NOT NULL,
public_key_fingerprint TEXT NOT NULL,
binding_id TEXT NOT NULL CHECK (length(binding_id) > 0),
state TEXT NOT NULL CHECK (state IN ('configured', 'verified', 'revoked')),
authentication_mode TEXT NOT NULL CHECK (authentication_mode IN ('legacy_server_issuer', 'workspace_identity')),
workspace_key_id TEXT,
workspace_public_key_fingerprint TEXT,
workspace_trust_id TEXT CHECK (length(workspace_trust_id) > 0),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
revoked_at TEXT,
PRIMARY KEY (workspace_id, runtime_id),
UNIQUE (workspace_id, public_key_fingerprint),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT,
CHECK (
(authentication_mode = 'legacy_server_issuer' AND workspace_key_id IS NULL AND workspace_public_key_fingerprint IS NULL AND workspace_trust_id IS NULL)
OR
(authentication_mode = 'workspace_identity' AND workspace_key_id IS NOT NULL AND (state != 'verified' OR (workspace_trust_id IS NOT NULL AND workspace_public_key_fingerprint IS NOT NULL)))
),
CHECK (
(state = 'revoked' AND revoked_at IS NOT NULL)
OR
(state != 'revoked' AND revoked_at IS NULL)
)
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT
);
CREATE TABLE workspace_runtime_verifications (
-- Historical authentication metadata only, never current admission authority.
CREATE TABLE workspace_runtime_binding_verification_archive (
workspace_id TEXT NOT NULL,
runtime_id TEXT NOT NULL,
binding_id TEXT NOT NULL CHECK(length(binding_id) > 0),
binding_id TEXT NOT NULL,
state TEXT NOT NULL,
workspace_key_id TEXT,
workspace_public_key_fingerprint TEXT,
workspace_trust_id TEXT,
updated_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, runtime_id, binding_id),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
);
CREATE TABLE workspace_runtime_verification_archive (
workspace_id TEXT NOT NULL,
runtime_id TEXT NOT NULL,
binding_id TEXT NOT NULL,
workspace_key_id TEXT NOT NULL,
workspace_public_key_fingerprint TEXT NOT NULL CHECK(length(workspace_public_key_fingerprint) > 0),
workspace_trust_id TEXT NOT NULL CHECK(length(workspace_trust_id) > 0),
workspace_public_key_fingerprint TEXT NOT NULL,
workspace_trust_id TEXT NOT NULL,
runtime_public_key_fingerprint TEXT NOT NULL,
challenge_id TEXT NOT NULL,
state TEXT NOT NULL CHECK(state IN ('pending', 'verified', 'failed')),
state TEXT NOT NULL,
last_outcome TEXT NOT NULL,
verified_at TEXT,
checked_at TEXT NOT NULL,
PRIMARY KEY(workspace_id, runtime_id),
FOREIGN KEY(workspace_id, runtime_id)
REFERENCES workspace_runtime_bindings(workspace_id, runtime_id) ON DELETE CASCADE,
CHECK((state = 'verified' AND verified_at IS NOT NULL)
OR (state != 'verified' AND verified_at IS NULL))
PRIMARY KEY (workspace_id, runtime_id),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
);
CREATE INDEX workspace_runtime_verifications_state_idx
ON workspace_runtime_verifications(workspace_id, state, checked_at DESC);
CREATE TABLE workspace_runtime_binding_audit (
workspace_id TEXT NOT NULL,
runtime_id TEXT NOT NULL,
@@ -1699,7 +1693,7 @@ CREATE TABLE IF NOT EXISTS ticket_assignment_work_releases (
FOREIGN KEY (workspace_id, assignment_id)
REFERENCES ticket_worker_assignments(workspace_id, assignment_id) ON DELETE CASCADE
);
-- Ticket case assignment work constraints (schema 90).
-- Ticket case assignment work constraints (schema 91).
DROP VIEW IF EXISTS ticket_active_worker_assignments;
CREATE VIEW ticket_active_worker_assignments AS
SELECT current.* FROM ticket_current_worker_assignments AS current
+2 -10
View File
@@ -10,7 +10,7 @@ use yoi_workspace_server::hosts::{
};
use yoi_workspace_server::store::{
AccountRecord, ApiTokenRecord, SqliteWorkspaceStore, UserRecord,
WorkspaceRuntimeAuthenticationMode, WorkspaceRuntimeBinding, WorkspaceRuntimeBindingState,
WorkspaceRuntimeAuthenticationMode, WorkspaceRuntimeBinding,
};
use yoi_workspace_server::{
ControlPlaneStore, ResolvedWorkspaceBackendConfig, ServerConfig, ServerHostConfigFile,
@@ -456,7 +456,7 @@ fn append_workspace_runtime_sources(
.into_iter()
.filter(|binding| {
binding.runtime_id != yoi_workspace_server::hosts::EMBEDDED_RUNTIME_ID
&& binding.state == WorkspaceRuntimeBindingState::Verified
&& binding.revoked_at.is_none()
})
.collect::<Vec<_>>()
})
@@ -787,11 +787,7 @@ mod tests {
public_key: "unused".to_owned(),
public_key_fingerprint: "unused".to_owned(),
binding_id: "binding-test".to_owned(),
state: WorkspaceRuntimeBindingState::Verified,
authentication_mode: WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer,
workspace_key_id: None,
workspace_public_key_fingerprint: None,
workspace_trust_id: None,
created_at: "2026-09-01T00:00:00Z".to_owned(),
updated_at: "2026-09-01T00:00:00Z".to_owned(),
revoked_at: None,
@@ -815,11 +811,7 @@ mod tests {
public_key: "unused".to_owned(),
public_key_fingerprint: "unused".to_owned(),
binding_id: "binding-test".to_owned(),
state: WorkspaceRuntimeBindingState::Verified,
authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity,
workspace_key_id: Some("WK-test".to_owned()),
workspace_public_key_fingerprint: Some("sha256:workspace-key".to_owned()),
workspace_trust_id: Some("trust-test".to_owned()),
created_at: "2026-09-01T00:00:00Z".to_owned(),
updated_at: "2026-09-01T00:00:00Z".to_owned(),
revoked_at: None,
@@ -80,10 +80,10 @@ fn migration_dry_run_and_apply_share_the_original_repository_secret_authority()
let conn = Connection::open(&path).unwrap();
configure_sqlite(&conn).unwrap();
seed_migration_workspace(&conn);
conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','create-key','intent','credential','key',7,'created');
INSERT INTO repository_ssh_credentials VALUES('space','key','Key','ssh-ed25519','fingerprint',7,'active','created',NULL);
INSERT INTO repository_ssh_credential_revisions VALUES('space','key',7,'ssh-ed25519','fingerprint','created');
INSERT INTO repository_secret_audit_events VALUES('space','audit','credential_created','key',7,'owner','created');").unwrap();
conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','create-key','intent','credential','key',7,'2026-01-01T00:00:00.000Z');
INSERT INTO repository_ssh_credentials VALUES('space','key','Key','ssh-ed25519','fingerprint',7,'active','2026-01-01T00:00:00.000Z',NULL);
INSERT INTO repository_ssh_credential_revisions VALUES('space','key',7,'ssh-ed25519','fingerprint','2026-01-01T00:00:00.000Z');
INSERT INTO repository_secret_audit_events VALUES('space','audit','credential_created','key',7,'owner','2026-01-01T00:00:00.000Z');").unwrap();
let key = LessSafeKey::new(UnboundKey::new(&AES_256_GCM, &[42; 32]).unwrap());
let mut ciphertext = b"private bytes".to_vec();
key.seal_in_place_append_tag(
@@ -92,7 +92,7 @@ fn migration_dry_run_and_apply_share_the_original_repository_secret_authority()
&mut ciphertext,
)
.unwrap();
conn.execute("INSERT INTO server_secret_versions VALUES('space','key',7,'private_key','aes-256-gcm-v1',?1,?2,'created')",params![[1u8;12].as_slice(),ciphertext]).unwrap();
conn.execute("INSERT INTO server_secret_versions VALUES('space','key',7,'private_key','aes-256-gcm-v1',?1,?2,'2026-01-01T00:00:00.000Z')",params![[1u8;12].as_slice(),ciphertext]).unwrap();
drop(conn);
let master_key = dir.path().join("repository-secrets.master-key");
std::fs::write(&master_key, [42; 32]).unwrap();
@@ -53,7 +53,8 @@ fn fresh_and_upgraded_state_contract_schemas_enforce_the_same_constraints() {
"flow_source_contents",
"repositories",
"workspace_runtime_bindings",
"workspace_runtime_verifications",
"workspace_runtime_binding_verification_archive",
"workspace_runtime_verification_archive",
"workspace_runtime_binding_audit",
"workspace_signing_identities",
"workspace_signing_identity_provisioning_operations",
File diff suppressed because it is too large Load Diff
@@ -111,11 +111,9 @@ impl RealDriveFixture {
public_key: identity.public_key.clone(),
public_key_fingerprint: String::new(),
binding_id: "binding-drive-test".to_string(),
state: StoredRuntimeBindingState::Verified,
authentication_mode: StoredRuntimeAuthenticationMode::LegacyServerIssuer,
workspace_key_id: None,
workspace_public_key_fingerprint: None,
workspace_trust_id: None,
created_at: "1".into(),
updated_at: "1".into(),
revoked_at: None,
@@ -160,12 +160,10 @@ impl LaunchFixture {
if remote {
fixture.runtime_id = "ticket-worker-real-http".to_string();
use worker_runtime::workspace_issuer::{
InMemoryWorkspaceClaimReplayProtection,
InMemoryWorkspaceRuntimeVerificationAuthority, RuntimeVerificationSigner,
WorkspaceCapabilityVerifier, WorkspaceIssuerTrustRecord, WorkspaceIssuerTrustState,
InMemoryWorkspaceClaimReplayProtection, WorkspaceCapabilityVerifier,
WorkspaceIssuerTrustRecord, WorkspaceIssuerTrustState,
};
let runtime_identity = RuntimeIdentityMaterial::generate(&fixture.runtime_id).unwrap();
let signer = RuntimeVerificationSigner::from_identity(&runtime_identity).unwrap();
let workspace_identity = fixture
.api
.signing_identities
@@ -204,13 +202,11 @@ impl LaunchFixture {
display_name: "Ticket Worker real HTTP Runtime".into(),
base_url: endpoint.clone(),
public_key: runtime_identity.public_key.clone(),
public_key_fingerprint: signer.public_key_fingerprint().to_string(),
public_key_fingerprint: String::new(),
binding_id: "binding-launch-test".to_string(),
state: StoredRuntimeBindingState::Configured,
authentication_mode: StoredRuntimeAuthenticationMode::WorkspaceIdentity,
workspace_key_id: Some(workspace_identity.key_id),
workspace_public_key_fingerprint: workspace_identity.public_key_fingerprint.clone(),
workspace_trust_id: Some("trust-launch-test".to_string()),
created_at: TEST_CREATED_AT.into(),
updated_at: TEST_CREATED_AT.into(),
revoked_at: None,
@@ -228,6 +224,7 @@ impl LaunchFixture {
.await
.unwrap()
.unwrap();
let runtime_id = fixture.runtime_id.clone();
fixture.runtime_server = Some(tokio::spawn(async move {
worker_runtime::http_server::serve_runtime_http_with_workspace_auth(
runtime,
@@ -235,10 +232,7 @@ impl LaunchFixture {
None,
worker_runtime::http_server::WorkspaceRuntimeHttpAuth {
verifier,
signer,
verifications: Arc::new(
InMemoryWorkspaceRuntimeVerificationAuthority::default(),
),
runtime_id,
},
)
.await
@@ -258,27 +252,12 @@ impl LaunchFixture {
cached_status: "active".into(),
timeout: std::time::Duration::from_secs(5),
};
fixture.api.runtime.register_or_replace(
RemoteWorkerRuntime::new(
remote_config.clone(),
TEST_WORKSPACE_ID.to_string(),
backend_url.clone(),
)
.unwrap(),
);
let verified = perform_workspace_runtime_verification(
&fixture.api,
fixture.api.runtime.clone(),
&binding,
)
.await
.unwrap();
remote_config.workspace_authorization =
Some(crate::hosts::WorkspaceRuntimeAuthorization::new(
fixture.api.store.clone(),
fixture.api.signing_identities.clone(),
backend_url.clone(),
Some(verified),
binding,
));
fixture.api.runtime.register_or_replace(
RemoteWorkerRuntime::new(remote_config, TEST_WORKSPACE_ID.to_string(), backend_url)
@@ -694,7 +694,6 @@ async fn revoked_runtime_and_persisted_workspace_fence_reject_new_requests() {
.unwrap()
.unwrap();
binding.revoked_at = Some("revoked".into());
binding.state = StoredRuntimeBindingState::Revoked;
f.api
.store
.upsert_workspace_runtime_binding_record(binding, true)
File diff suppressed because it is too large Load Diff
@@ -102,7 +102,8 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
"workspace_resource_keys",
"workspace_runtime_binding_audit",
"workspace_runtime_bindings",
"workspace_runtime_verifications",
"workspace_runtime_binding_verification_archive",
"workspace_runtime_verification_archive",
"workspace_signing_identities",
"workspace_signing_identity_audit",
"workspace_signing_identity_provisioning_operations",
@@ -228,36 +228,7 @@ impl WorkspaceSigningIdentityService {
}
pub fn sign(&self, workspace_id: &str, payload: &[u8]) -> Result<Vec<u8>> {
let identity = self.get_validated(workspace_id)?;
if identity.state != "active" {
return Err(identity_error(
"workspace_signing_identity_not_provisioned",
"Workspace signing identity is not provisioned",
));
}
let material = self
.materials
.load(&identity.private_material_ref)?
.ok_or_else(|| {
identity_error(
"workspace_signing_identity_material_missing",
"Workspace signing private material is missing",
)
})?;
let fingerprint = identity.public_key_fingerprint.as_deref().ok_or_else(|| {
identity_error(
"workspace_signing_identity_material_mismatch",
"Workspace signing public key fingerprint is missing",
)
})?;
let signing_key = material.signing_key(workspace_id, &identity.key_id)?;
let public_key = encode_public_key(signing_key.public_key().as_ref());
if public_key_fingerprint(&public_key)? != fingerprint {
return Err(identity_error(
"workspace_signing_identity_material_mismatch",
"Workspace signing private material does not match public metadata",
));
}
let (_, signing_key) = self.select_signing_key(workspace_id)?;
Ok(signing_key.sign(payload).as_ref().to_vec())
}
@@ -266,9 +237,20 @@ impl WorkspaceSigningIdentityService {
workspace_id: &str,
claims: &WorkspaceCapabilityClaims,
) -> Result<String> {
let (identity, signing_key) = self.select_signing_key(workspace_id)?;
if claims.issuer_workspace_id != identity.workspace_id
|| claims.issuer_key_id != identity.key_id
|| Some(claims.issuer_public_key_fingerprint.as_str())
!= identity.public_key_fingerprint.as_deref()
{
return Err(identity_error(
"workspace_capability_identity_mismatch",
"Workspace capability claims do not match the current signing identity",
));
}
let input = workspace_capability_signing_input(claims).map_err(capability_error)?;
let signature = self.sign(workspace_id, input.bytes())?;
assemble_workspace_capability_token(input, &signature).map_err(capability_error)
let signature = signing_key.sign(input.bytes());
assemble_workspace_capability_token(input, signature.as_ref()).map_err(capability_error)
}
pub fn delete_material(&self, workspace_id: &str) -> Result<()> {
@@ -306,7 +288,40 @@ impl WorkspaceSigningIdentityService {
})
}
fn select_signing_key(
&self,
workspace_id: &str,
) -> Result<(
WorkspaceSigningIdentityRecord,
ring::signature::Ed25519KeyPair,
)> {
let identity = self
.store
.get_workspace_signing_identity(workspace_id)?
.ok_or_else(|| {
identity_error(
"workspace_signing_identity_metadata_missing",
"Workspace signing identity metadata is missing",
)
})?;
if identity.state != "active" {
return Err(identity_error(
"workspace_signing_identity_not_provisioned",
"Workspace signing identity is not provisioned",
));
}
let signing_key = self.validated_signing_key(&identity)?;
Ok((identity, signing_key))
}
fn validate_active_material(&self, identity: &WorkspaceSigningIdentityRecord) -> Result<()> {
self.validated_signing_key(identity).map(|_| ())
}
fn validated_signing_key(
&self,
identity: &WorkspaceSigningIdentityRecord,
) -> Result<ring::signature::Ed25519KeyPair> {
let material = self
.materials
.load(&identity.private_material_ref)?
@@ -316,8 +331,8 @@ impl WorkspaceSigningIdentityService {
"Workspace signing private material is missing",
)
})?;
let public_key =
material.validate_and_public_key(&identity.workspace_id, &identity.key_id)?;
let signing_key = material.signing_key(&identity.workspace_id, &identity.key_id)?;
let public_key = encode_public_key(signing_key.public_key().as_ref());
let fingerprint = public_key_fingerprint(&public_key)?;
if identity.public_key.as_deref() != Some(public_key.as_str())
|| identity.public_key_fingerprint.as_deref() != Some(fingerprint.as_str())
@@ -327,7 +342,7 @@ impl WorkspaceSigningIdentityService {
"Workspace signing private material does not match public metadata",
));
}
Ok(())
Ok(signing_key)
}
}
@@ -843,6 +858,14 @@ mod tests {
loads: std::sync::atomic::AtomicUsize::new(0),
}),
);
let selected_signature = swapping_service.sign("workspace-1", payload).unwrap();
ring::signature::UnparsedPublicKey::new(
&ring::signature::ED25519,
worker_runtime::auth::decode_public_key(provisioned.public_key.as_deref().unwrap())
.unwrap(),
)
.verify(payload, &selected_signature)
.unwrap();
assert!(matches!(
swapping_service.sign("workspace-1", payload),
Err(Error::WorkspaceSigningIdentity { ref code, .. })