Files
core/test/dever-tests/tests/secure_contracts.rs
T

577 lines
18 KiB
Rust

mod support;
use dever_core::source::SourceMap;
use dever_runtime::{bytes::Bytes, crypto, secret::Secret};
fn sources(files: &[(&str, &str)]) -> SourceMap {
let mut sources = SourceMap::default();
for (path, text) in files {
sources.add(*path, *text);
}
sources
}
fn rejected(files: &[(&str, &str)], message: &str) {
let sources = sources(files);
let errors = dever_core::check(&sources).expect_err("must reject unsafe source");
assert!(
errors.iter().any(|error| error.message.contains(message)),
"{}",
errors
.iter()
.map(|error| error.render(&sources))
.collect::<String>()
);
}
const ACCOUNT: &str = "type Account { email: Text\nprivate password_hash: Text(255) }";
fn checked_platform(sources: &SourceMap) -> dever_core::hir::Program {
let root = support::temp::TemporaryDirectory::new();
std::fs::create_dir(root.path().join("config")).unwrap();
std::fs::write(
root.path().join("config/setting.json"),
r#"{"database":{
"default":{"type":"sqlite","path":"data/default.db"},
"platform":{"type":"sqlite","path":"data/platform.db"}
}}"#,
)
.unwrap();
let settings = dever_runtime::config::Settings::load_project(root.path()).unwrap();
let program = support::checked(sources);
program.validate_database_settings(&settings).unwrap();
program
}
#[test]
fn password_crypto_api_requires_a_short_explicit_write_transaction() {
let account = format!("database platform\nglobal {ACCOUNT}");
let sources = sources(&[
("user/account/model.dever", &account),
(
"user/account/app.dever",
r#"
login(credential_id: model.id, password: Secret) (id: model.id) {
stored = model.get(credential_id)
valid = blocking(dever.crypto.password_verify(password, stored.password_hash))
dever.api.set_header("x-login", "accepted")
id = store(credential_id)
}
transaction store(credential_id: model.id) (id: model.id) {
stored = model.get(credential_id)
created = model.create({ email = stored.email
password_hash = stored.password_hash })
id = created.id
}
"#,
),
(
"user/account/api/front/account.dever",
"public long post login = app.login",
),
]);
let program = checked_platform(&sources);
let generated = dever_core::native::emit_project(
&program,
&sources,
dever_runtime::config::RuntimeProfile {
sqlite: true,
postgres: false,
},
)
.unwrap();
assert!(!generated.contains("begin_deferred"), "{generated}");
assert_eq!(
generated
.matches("transaction_database.begin().await")
.count(),
1,
"{generated}"
);
rejected(
&[
("user/account/model.dever", &account),
(
"user/account/app.dever",
r#"
login(credential_id: model.id, password: Secret) (id: model.id) {
stored = model.get(credential_id)
valid = blocking(dever.crypto.password_verify(password, stored.password_hash))
created = model.create({ email = stored.email
password_hash = stored.password_hash })
id = created.id
}
"#,
),
(
"user/account/api/front/account.dever",
"public long post login = app.login",
),
],
"explicit transaction function",
);
rejected(
&[
("user/account/model.dever", &account),
(
"user/account/app.dever",
r#"
transaction login(credential_id: model.id, password: Secret) (id: model.id) {
stored = model.get(credential_id)
valid = blocking(dever.crypto.password_verify(password, stored.password_hash))
id = stored.id
}
"#,
),
],
"cannot perform password hashing or verification",
);
}
#[test]
fn ordinary_write_api_commits_before_publishing_response_metadata() {
let sources = sources(&[
("user/account/model.dever", ACCOUNT),
(
"user/account/app.dever",
r#"
register(email: Text, password_hash: Text) (id: model.id) {
dever.api.set_header("x-register", "accepted")
stored = model.create({ email = email
password_hash = password_hash })
id = stored.id
}
"#,
),
("user/account/api.dever", "post register = app.register"),
]);
let program = support::checked(&sources);
let generated = dever_core::native::emit_project(
&program,
&sources,
dever_runtime::config::RuntimeProfile {
sqlite: true,
postgres: false,
},
)
.unwrap();
let commit = generated
.find("if let Err(error) = transaction.commit().await")
.unwrap();
let metadata = generated
.find("dever_runtime::api::commit_response_metadata()")
.unwrap();
assert!(
commit < metadata,
"response metadata must be published only after the database commits"
);
}
#[cfg(feature = "sqlite")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn password_work_window_does_not_hold_the_sqlite_writer_lock() {
use std::sync::Arc;
use std::time::Duration;
use dever_runtime::orm::Value;
use dever_runtime::sqlite::Database;
let root = support::temp::TemporaryDirectory::new();
let database = Arc::new(Database::open(root.path().join("login.db"), 2, 20).unwrap());
database
.execute_batch(
"CREATE TABLE account(id INTEGER PRIMARY KEY,status TEXT NOT NULL);\
CREATE TABLE session(id INTEGER PRIMARY KEY,account_id INTEGER NOT NULL);\
CREATE TABLE platform_write(id INTEGER PRIMARY KEY,value INTEGER NOT NULL);\
INSERT INTO account(id,status) VALUES(1,'active');\
INSERT INTO platform_write(id,value) VALUES(1,0);"
.into(),
)
.await
.unwrap();
let account = database
.query(
"SELECT status FROM account WHERE id=?1",
vec![Value::Int(1)],
)
.await
.unwrap();
assert_eq!(account.len(), 1);
let (started, waiting) = tokio::sync::oneshot::channel();
let password_work = tokio::task::spawn_blocking(move || {
let _ = started.send(());
std::thread::sleep(Duration::from_millis(500));
crypto::password_hash(&Secret::from_input(b"correct password".to_vec())).unwrap()
});
waiting.await.unwrap();
tokio::time::timeout(Duration::from_millis(250), async {
let transaction = database.begin().await.unwrap();
transaction
.execute(
"UPDATE platform_write SET value=value+1 WHERE id=?1",
vec![Value::Int(1)],
)
.await
.unwrap();
transaction.commit().await.unwrap();
})
.await
.expect("unrelated write must not wait for password work");
assert!(
!password_work.is_finished(),
"the write must finish during the password work window"
);
let _hash = password_work.await.unwrap();
let transaction = database.begin().await.unwrap();
let active = transaction
.query(
"SELECT status FROM account WHERE id=?1 AND status='active'",
vec![Value::Int(1)],
)
.await
.unwrap();
assert_eq!(
active.len(),
1,
"mutable account state is rechecked inside the short transaction"
);
transaction
.execute(
"INSERT INTO session(id,account_id) VALUES(?1,?2)",
vec![Value::Int(1), Value::Int(1)],
)
.await
.unwrap();
transaction.commit().await.unwrap();
assert_eq!(
database
.query("SELECT id FROM session", vec![])
.await
.unwrap()
.len(),
1
);
}
#[test]
fn private_model_fields_are_app_owned_and_do_not_change_storage_schema() {
let plain = sources(&[("user/account/model.dever", &ACCOUNT.replace("private ", ""))]);
let private = sources(&[("user/account/model.dever", ACCOUNT)]);
assert_eq!(
support::checked(&plain).model_snapshot(),
support::checked(&private).model_snapshot()
);
support::checked(&sources(&[
("user/account/model.dever", ACCOUNT),
(
"user/account/app.dever",
r#"
register(password: Secret) (id: model.id) {
hash = blocking(dever.crypto.password_hash(password))
stored = model.create({ email = "a@example.com"
password_hash = hash })
changed = model.update(stored.id, { password_hash = hash })
valid = blocking(dever.crypto.password_verify(password, stored.password_hash))
id = stored.id
}
"#,
),
]));
for caller in ["utility.dever", "other.dever"] {
rejected(
&[
("user/account/model.dever", ACCOUNT),
(
caller,
"read(stored: user.account.model.Account) (hash: Text) { hash = stored.password_hash }",
),
],
"private field",
);
}
rejected(
&[
("user/account/model.dever", ACCOUNT),
(
"utility.dever",
"rewrite(stored: user.account.model.Account) () { stored.password_hash = \"replacement\" }",
),
],
"private field",
);
}
#[test]
fn private_models_cannot_cross_app_boundaries_or_render_nested_errors() {
for declaration in [
"leak() (stored: model.Account) { stored = model.get(1) }",
"accept(stored: model.Account) () {}",
"type Envelope { values: List<model.Account?> }\naccept(value: Envelope) () {}",
"type Failure { error Failed(value: model.Account) }",
"accept(page: model.Page) () {}",
"accept(values: Related<List<model.Account>>) () {}",
] {
rejected(
&[
("user/account/model.dever", ACCOUNT),
("user/account/app.dever", declaration),
],
"private Model",
);
}
}
#[test]
fn secrets_are_non_observable_at_every_container_and_error_boundary() {
for ty in [
"Secret",
"Secret?",
"List<Secret>",
"Map<Text, Secret>",
"Envelope",
] {
let source = format!(
"public type Envelope {{ value: Secret }}\npublic expose(value: {ty}) (out: {ty}) {{ out = value }}"
);
rejected(&[("utility.dever", &source)], "output Secret");
}
for source in [
"type Failure { error Failed(value: Secret) }",
"type Nested { value: List<Secret> }\ntype Failure { error Failed(value: Nested) }",
] {
rejected(&[("utility.dever", source)], "error payload");
}
for (source, expected) in [
(
"compare(a: Secret, b: Secret) (same: Bool) { same = a == b }",
"comparable",
),
(
"join(a: Secret) (value: Text) { value = a + \"x\" }",
"numeric",
),
("bad(value: Map<Secret, Text>) () {}", "Map key"),
("main() () { value = secret(\"password\") }", "unknown"),
(
"log(value: Secret) () { dever.log.info(value, {}) }",
"expected Text",
),
] {
rejected(&[("utility.dever", source)], expected);
}
rejected(
&[(
"user/account/model.dever",
"type Account { password: Secret }",
)],
"Model field",
);
}
#[test]
fn public_post_does_not_guess_database_not_found_authentication_semantics() {
let account = format!("database platform\nglobal {ACCOUNT}");
let sources = sources(&[
("user/account/model.dever", &account),
(
"user/account/app.dever",
"lookup(id: model.id) (email: Text) { stored = model.get(id)\nemail = stored.email }",
),
(
"user/account/api/front/account.dever",
"public post lookup = app.lookup",
),
]);
let program = checked_platform(&sources);
let generated = dever_core::native::emit_project(
&program,
&sources,
dever_runtime::config::RuntimeProfile {
sqlite: true,
postgres: false,
},
)
.unwrap();
assert!(
!generated.contains("api_anonymous_post_failure_response"),
"{generated}"
);
}
#[test]
fn removed_process_configuration_has_no_source_entry() {
for function in [
"dever.process.environment",
"dever.system.process_environment",
] {
support::rejected(
&format!("public main() () {{ ignored = {function}(\"setting\") }}"),
"unknown",
);
}
}
fn test_sources(test: &str) -> (support::temp::TemporaryDirectory, SourceMap) {
let root = support::temp::TemporaryDirectory::new();
std::fs::create_dir_all(root.path().join("module")).unwrap();
std::fs::create_dir_all(root.path().join("test/user/account")).unwrap();
std::fs::write(root.path().join("module/main.dever"), "main() () {}").unwrap();
std::fs::write(
root.path().join("test/user/account/credentials.dever"),
test,
)
.unwrap();
let sources =
SourceMap::load_project(&root.path().join("module"), &root.path().join("test")).unwrap();
(root, sources)
}
#[test]
fn secret_fixture_authority_and_native_rendering_are_checked() {
let (_root, sources) = test_sources(
r#"
credentials() () {
password = secret("test password")
hash = dever.crypto.password_hash(password)
assert(dever.crypto.password_verify(password, hash))
assert(not dever.crypto.password_verify(secret("wrong"), hash))
}
"#,
);
let program = support::checked(&sources);
let rustc = std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into());
let native = dever_core::native::compile_test_suite(&program, &sources, &rustc).unwrap();
let output = std::process::Command::new(native.executable())
.arg("0")
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(output.stdout.is_empty());
for test in [
"credentials() () { assert_eq(secret(\"a\"), secret(\"a\")) }",
"credentials() () {}\nsecret(value: Text) (out: Text) { out = value }",
] {
let (_root, sources) = test_sources(test);
assert!(dever_core::check(&sources).is_err());
}
let (_root, mut sources) = test_sources(
"credentials() () {}\nread(stored: model.Account) (hash: Text) { hash = stored.password_hash }",
);
sources.add("user/account/model.dever", ACCOUNT);
let errors = dever_core::check(&sources).expect_err("Test cannot read private Model fields");
assert!(
errors
.iter()
.any(|error| error.message.contains("private field"))
);
}
#[test]
fn model_privacy_follows_related_cycles_without_affecting_layout_validation() {
rejected(
&[
(
"user/account/model.dever",
"type Account { private password_hash: Text\nfriend_id: user.account.model.id? }",
),
(
"user/account/app.dever",
"accept(value: Related<model.Account>) () {}",
),
],
"private Model",
);
}
#[test]
fn crypto_known_answers_and_password_cost_limits() {
let password = Secret::from_input(b"correct horse battery staple".to_vec());
let hash = crypto::password_hash(&password).unwrap();
assert!(hash.starts_with("$argon2id$v=19$m=19456,t=2,p=1$"));
assert!(crypto::password_verify(&password, &hash).unwrap());
assert!(!crypto::password_verify(&Secret::from_input(b"wrong".to_vec()), &hash).unwrap());
for bad in [
"malformed".to_owned(),
hash.replace("m=19456", "m=19457"),
hash.replace("t=2", "t=3"),
hash.replace("p=1", "p=2"),
hash.replace("argon2id", "argon2i"),
hash.replace("v=19", "v=16"),
] {
assert_eq!(
crypto::password_verify(&password, &bad).unwrap_err(),
"invalid or unsupported password hash"
);
}
assert_eq!(format!("{password:?}"), "Secret([REDACTED])");
let abc = Bytes::from_text("abc");
assert_eq!(
crypto::sha256(&abc).values(),
&[
0xba, 0x78, 0x16, 0xbf, 0x8f, 0x01, 0xcf, 0xea, 0x41, 0x41, 0x40, 0xde, 0x5d, 0xae,
0x22, 0x23, 0xb0, 0x03, 0x61, 0xa3, 0x96, 0x17, 0x7a, 0x9c, 0xb4, 0x10, 0xff, 0x61,
0xf2, 0x00, 0x15, 0xad
]
);
// RFC 4231 test case 1.
let digest = crypto::hmac_sha256(
&Secret::from_input(vec![0x0b; 20]),
&Bytes::from_text("Hi There"),
);
assert_eq!(
digest.values(),
&[
0xb0, 0x34, 0x4c, 0x61, 0xd8, 0xdb, 0x38, 0x53, 0x5c, 0xa8, 0xaf, 0xce, 0xaf, 0x0b,
0xf1, 0x2b, 0x88, 0x1d, 0xc2, 0x00, 0xc9, 0x83, 0x3d, 0xa7, 0x26, 0xe9, 0x37, 0x6c,
0x2e, 0x32, 0xcf, 0xf7
]
);
assert!(crypto::constant_time_eq(&digest, &digest));
assert!(!crypto::constant_time_eq(&digest, &abc));
let first = crypto::token(32).unwrap();
let second = crypto::token(32).unwrap();
assert!(!crypto::constant_time_eq(
&crypto::hmac_sha256(&first, &abc),
&crypto::hmac_sha256(&second, &abc)
));
assert!(crypto::token(0).is_err());
assert!(crypto::token(1025).is_err());
}
#[test]
fn native_crypto_and_direct_system_intrinsics_select_crypto_runtime() {
let source = r#"public main() (length: Int, valid: Bool) {
key = dever.crypto.token(32)
hash = dever.crypto.password_hash(key)
valid = dever.crypto.password_verify(key, hash)
bytes = dever.system.crypto_sha256(dever.bytes.from_text("abc"))
length = dever.bytes.length(bytes)
}"#;
assert_eq!(support::stdout(source), "length = 32\nvalid = true\n");
#[cfg(feature = "reference")]
{
let sources = support::sources(source);
let program = support::checked(&sources);
let output = dever_core::reference::evaluate(&program, "main.main").unwrap();
assert!(output.fault.is_none());
assert_eq!(output.stdout, "length = 32\nvalid = true\n");
}
assert_eq!(
support::stdout(
"public main() (length: Int) { length = dever.bytes.length(dever.system.crypto_sha256(dever.bytes.from_text(\"abc\"))) }"
),
"length = 32\n"
);
}