Implement SSH authorized key management

This commit is contained in:
2026-09-08 16:37:47 +02:00
parent 22a7c7b0a5
commit 42f6423d4e
19 changed files with 930 additions and 83 deletions
+126
View File
@@ -0,0 +1,126 @@
/* Included in accounts.py's canonical DB/console transaction harness.
* Production SSH parsing, with OpenSSL-backed curve/SHA adapters, not mbedTLS. */
static user_database_account_t key_target(void)
{
user_database_accounts_t accounts;
assert(user_database_get_accounts(&accounts)==ESP_OK);
return accounts.users[1];
}
static size_t ssh_string(uint8_t *out, const void *value, size_t length)
{
out[0]=out[1]=out[2]=0; out[3]=(uint8_t)length;
memcpy(out+4,value,length); return length+4;
}
static void stale_keys(const user_database_account_t *target, const uint8_t *blob, size_t length)
{
stored_database_t before=s_database;
unsigned saved=commits;
uint8_t index=0;
user_database_user_snapshot_t snapshot;
assert(user_database_add_ssh_key_current(target,s_ed25519_type,11,blob,length,&index)==ESP_ERR_NOT_FOUND);
assert(user_database_remove_ssh_key_current(target,0)==ESP_ERR_NOT_FOUND);
assert(user_database_clear_ssh_keys_current(target)==ESP_ERR_NOT_FOUND);
memset(&snapshot,0xff,sizeof(snapshot));
assert(user_database_get_account_keys(target,&snapshot)==ESP_ERR_NOT_FOUND);
assert(all_zero(&snapshot,sizeof(snapshot)) && commits==saved); unchanged(&before);
}
static void typed_key_tests(void)
{
reset();
uint8_t ed[128]={0}, p256[128]={0}, point[65], public[32]={1};
size_t en=ssh_string(ed,s_ed25519_type,11); en+=ssh_string(ed+en,public,32);
EC_GROUP *group=EC_GROUP_new_by_curve_name(NID_X9_62_prime256v1);
assert(group && EC_POINT_point2oct(group,EC_GROUP_get0_generator(group),POINT_CONVERSION_UNCOMPRESSED,point,sizeof(point),NULL)==65);
EC_GROUP_free(group);
size_t pn=ssh_string(p256,s_ecdsa_type,19);
pn+=ssh_string(p256+pn,s_ecdsa_curve,8); pn+=ssh_string(p256+pn,point,65);
assert(user_database_key_valid(s_ed25519_type,11,ed,en));
assert(user_database_key_valid(s_ecdsa_type,19,p256,pn));
for (size_t n=0;n<en;++n) assert(!user_database_key_valid(s_ed25519_type,11,ed,n));
for (size_t n=0;n<pn;++n) assert(!user_database_key_valid(s_ecdsa_type,19,p256,n));
assert(!user_database_key_valid(s_ed25519_type,11,ed,en+1));
assert(!user_database_key_valid(s_ecdsa_type,19,p256,pn+1));
assert(!user_database_key_valid(s_ed25519_type,11,p256,pn));
assert(!user_database_key_valid((const uint8_t *)"ssh-rsa",7,ed,en));
uint8_t bad[129]; memcpy(bad,p256,pn); bad[pn-65]=2;
assert(!user_database_key_valid(s_ecdsa_type,19,bad,pn));
memset(bad+pn-64,0,64); bad[pn-65]=4;
assert(!user_database_key_valid(s_ecdsa_type,19,bad,pn));
memcpy(bad,p256,pn); bad[27]='x';
assert(!user_database_key_valid(s_ecdsa_type,19,bad,pn));
memset(bad,0xff,sizeof(bad));
assert(!user_database_key_valid(s_ed25519_type,11,bad,sizeof(bad)));
user_database_account_t target=key_target();
user_database_user_snapshot_t snapshot;
snapshot_busy=true; memset(&snapshot,0xff,sizeof(snapshot));
assert(user_database_get_account_keys(&target,&snapshot)==ESP_ERR_TIMEOUT && last_wait==0);
assert(all_zero(&snapshot,sizeof(snapshot))); snapshot_busy=false;
assert(user_database_get_account_keys(&target,&snapshot)==ESP_OK && last_wait==0 && !snapshot.public_key_count);
uint8_t index=255;
stored_database_t invalid_before=s_database;
assert(user_database_add_ssh_key_current(&target,s_ed25519_type,11,bad,sizeof(bad),&index)==ESP_ERR_INVALID_ARG);
unchanged(&invalid_before);
for (fail_stage=1;fail_stage<=3;++fail_stage) {
stored_database_t before=s_database;
assert(user_database_add_ssh_key_current(&target,s_ed25519_type,11,ed,en,&index)==ESP_FAIL);
unchanged(&before);
}
fail_stage=0;
assert(user_database_add_ssh_key_current(&target,s_ed25519_type,11,ed,en,&index)==ESP_OK && index==0);
stale_keys(&target,ed,en); target=key_target();
user_principal_t authenticated; bool authorized=false;
assert(user_database_authorize_ssh_public_key((const uint8_t *)"other",5,s_ed25519_type,11,ed,en,&authenticated,&authorized)==ESP_OK && authorized);
assert(authenticated.method==USER_AUTH_METHOD_SSH_PUBLIC_KEY && authenticated.auth_generation==target.auth_generation);
assert(user_database_get_account_keys(&target,&snapshot)==ESP_OK && snapshot.public_key_count==1);
uint8_t digest[32]; assert(SHA256(ed,en,digest));
assert(snapshot.public_keys[0].active && !strcmp(snapshot.public_keys[0].key_type,"ssh-ed25519"));
assert(!memcmp(snapshot.public_keys[0].sha256_fingerprint,digest,32));
unsigned saved=commits;
assert(user_database_add_ssh_key_current(&target,s_ed25519_type,11,ed,en,&index)==USER_DATABASE_ERR_DUPLICATE_SSH_KEY && commits==saved);
assert(user_database_add_ssh_key_current(&target,s_ecdsa_type,19,p256,pn,&index)==ESP_OK && index==1);
target=key_target();
assert(user_database_authorize_ssh_public_key((const uint8_t *)"other",5,s_ecdsa_type,19,p256,pn,&authenticated,&authorized)==ESP_OK && authorized);
assert(user_database_authorize_ssh_public_key((const uint8_t *)"observer",8,s_ecdsa_type,19,p256,pn,&authenticated,&authorized)==ESP_OK && !authorized);
ed[en-1]=2;
assert(user_database_add_ssh_key_current(&target,s_ed25519_type,11,ed,en,&index)==ESP_OK && index==2);
target=key_target(); saved=commits;
assert(user_database_add_ssh_key_current(&target,s_ed25519_type,11,ed,en,&index)==USER_DATABASE_ERR_DUPLICATE_SSH_KEY);
ed[en-1]=3;
assert(user_database_add_ssh_key_current(&target,s_ed25519_type,11,ed,en,&index)==ESP_ERR_NO_MEM && commits==saved);
for (fail_stage=1;fail_stage<=3;++fail_stage) {
stored_database_t before=s_database;
assert(user_database_remove_ssh_key_current(&target,1)==ESP_FAIL); unchanged(&before);
assert(user_database_clear_ssh_keys_current(&target)==ESP_FAIL); unchanged(&before);
}
fail_stage=0;
assert(user_database_remove_ssh_key_current(&target,1)==ESP_OK);
assert(user_database_authorize_ssh_public_key((const uint8_t *)"other",5,s_ecdsa_type,19,p256,pn,&authenticated,&authorized)==ESP_OK && !authorized);
stale_keys(&target,ed,en); target=key_target();
assert(user_database_get_account_keys(&target,&snapshot)==ESP_OK && snapshot.public_key_count==2);
assert(!snapshot.public_keys[1].active && snapshot.public_keys[2].index==2);
assert(user_database_remove_ssh_key_current(&target,1)==ESP_ERR_NOT_FOUND);
assert(user_database_remove_ssh_key_current(&target,3)==ESP_ERR_INVALID_ARG);
assert(user_database_add_ssh_key_current(&target,s_ecdsa_type,19,p256,pn,&index)==ESP_OK && index==1);
target=key_target(); assert(user_database_clear_ssh_keys_current(&target)==ESP_OK);
stale_keys(&target,ed,en); target=key_target(); saved=commits;
assert(user_database_clear_ssh_keys_current(&target)==ESP_OK && commits==saved);
assert(user_database_delete_current(&target)==ESP_OK);
assert(user_database_create((const uint8_t *)"other",5,USER_ROLE_USER,(const uint8_t *)"test-password",13)==ESP_OK);
stale_keys(&target,ed,en);
target=key_target(); assert(user_database_set_role_current(&target,USER_ROLE_ADMIN)==ESP_OK); stale_keys(&target,ed,en);
target=key_target(); assert(user_database_set_password_current(&target,(const uint8_t *)"test-password",13)==ESP_OK); stale_keys(&target,ed,en);
target=key_target(); target.user_id=0; stale_keys(&target,ed,en);
target=key_target(); target.auth_generation=0; stale_keys(&target,ed,en);
target=key_target(); memset(target.username,'x',sizeof(target.username));
assert(user_database_add_ssh_key_current(&target,s_ed25519_type,11,ed,en,&index)==ESP_ERR_INVALID_ARG);
assert(user_database_remove_ssh_key_current(&target,0)==ESP_ERR_INVALID_ARG);
assert(user_database_clear_ssh_keys_current(NULL)==ESP_ERR_INVALID_ARG);
assert(user_database_get_account_keys(NULL,&snapshot)==ESP_ERR_INVALID_ARG && all_zero(&snapshot,sizeof(snapshot)));
/* Legacy CLI APIs retain the exact transaction path and generation changes. */
target=key_target(); assert(user_database_add_ssh_key((const uint8_t *)"other",5,s_ed25519_type,11,ed,en,&index)==ESP_OK);
stale_keys(&target,ed,en);
assert(user_database_remove_ssh_key((const uint8_t *)"other",5,index)==ESP_OK);
assert(user_database_clear_ssh_keys((const uint8_t *)"other",5)==ESP_OK);
s_initialized=false; memset(&snapshot,0xff,sizeof(snapshot));
assert(user_database_get_account_keys(&target,&snapshot)==ESP_ERR_INVALID_STATE && all_zero(&snapshot,sizeof(snapshot)));
}
+36 -14
View File
@@ -80,16 +80,33 @@ static int derive_password(const uint8_t *p, size_t n, const uint8_t *s,
}
return fail_stage==5 ? ESP_FAIL : ESP_OK;
}
#include <openssl/sha.h>
#include <openssl/ec.h>
#include <openssl/obj_mac.h>
typedef EC_GROUP *mbedtls_ecp_group;
typedef struct { EC_POINT *point; } mbedtls_ecp_point;
#define MBEDTLS_ECP_DP_SECP256R1 1
static void mbedtls_ecp_group_init(mbedtls_ecp_group *g) { *g=NULL; }
static void mbedtls_ecp_point_init(mbedtls_ecp_point *p) { p->point=NULL; }
static int mbedtls_ecp_group_load(mbedtls_ecp_group *g, int id) {
assert(id==1); *g=EC_GROUP_new_by_curve_name(NID_X9_62_prime256v1); return *g ? 0 : -1;
}
static int mbedtls_ecp_point_read_binary(mbedtls_ecp_group *g, mbedtls_ecp_point *p, const uint8_t *b, size_t n) {
p->point=EC_POINT_new(*g); return p->point && EC_POINT_oct2point(*g,p->point,b,n,NULL)==1 ? 0 : -1;
}
static int mbedtls_ecp_check_pubkey(mbedtls_ecp_group *g, mbedtls_ecp_point *p) {
return EC_POINT_is_at_infinity(*g,p->point)==0 && EC_POINT_is_on_curve(*g,p->point,NULL)==1 ? 0 : -1;
}
static void mbedtls_ecp_point_free(mbedtls_ecp_point *p) { EC_POINT_free(p->point); }
static void mbedtls_ecp_group_free(mbedtls_ecp_group *g) { EC_GROUP_free(*g); }
static int mbedtls_sha256(const uint8_t *p, size_t n, uint8_t *h, int mode) {
(void)p; (void)n; (void)h; (void)mode; assert(!"keys outside slice"); return -1;
assert(mode==0); return SHA256(p,n,h) ? 0 : -1;
}
'''
header = strip_includes((ROOT / "src/user_database.h").read_text())
state = db[db.index("#define USER_DATABASE_SCHEMA_VERSION"):db.index("static esp_err_t initialize_dummy_verifier(")]
fakes = r'''
bool user_database_key_valid(const uint8_t *t, size_t tn, const uint8_t *b, size_t bn) {
(void)t; (void)tn; (void)b; (void)bn; assert(!"keys outside slice"); return false;
}
static stored_database_t candidate_storage;
static user_principal_t actor;
static bool admin_ssh_console_dispatch_is_remote(void) { return remote; }
@@ -133,16 +150,11 @@ esp_err_t user_database_create_generated(const uint8_t *u,size_t n,user_role_t r
esp_err_t user_database_generate_password(const uint8_t *u,size_t n,user_database_generated_password_t *p) {
(void)u; (void)n; (void)p; assert(!"generated credential"); return ESP_FAIL;
}
esp_err_t user_database_remove_ssh_key(const uint8_t *u,size_t n,uint8_t i) {
(void)u; (void)n; (void)i; assert(!"key mutation"); return ESP_FAIL;
}
esp_err_t user_database_clear_ssh_keys(const uint8_t *u,size_t n) {
(void)u; (void)n; assert(!"key mutation"); return ESP_FAIL;
}
size_t esp_console_split_argv(char *, char **, size_t);
'''
db_names = ["constant_time_equal", "all_zero", "user_database_username_valid",
"user_database_password_valid",
"user_database_password_valid", "read_ssh_string", "user_database_key_valid",
"user_role_to_string", "user_role_parse", "set_record_password", "find_user",
"find_free_user", "stored_keys_equal", "validate_database", "recount",
"next_generation", "discard_candidate", "commit_candidate_locked", "initialize_user",
@@ -151,7 +163,11 @@ db_names = ["constant_time_equal", "all_zero", "user_database_username_valid",
"user_database_delete", "user_database_set_role", "user_database_get_accounts",
"user_database_delete_current", "user_database_set_role_current",
"set_password", "user_database_set_password", "user_database_set_password_current",
"user_database_generate_password_value"]
"user_database_generate_password_value",
"user_database_get_account_keys", "add_ssh_key", "remove_ssh_key", "clear_ssh_keys",
"user_database_add_ssh_key", "user_database_remove_ssh_key", "user_database_clear_ssh_keys",
"key_target_valid", "user_database_add_ssh_key_current", "user_database_remove_ssh_key_current",
"user_database_clear_ssh_keys_current", "fill_principal", "user_database_authorize_ssh_public_key"]
console_names = ["print_usage", "revoke_user_network_sessions", "read_password",
"show_generated_password", "mutation_currentness", "add_user", "change_password",
"parse_key_index", "command_user_inner", "command_user"]
@@ -159,15 +175,21 @@ unit = prelude + header + "\n" + state + fakes
unit += "\n".join(function(db, n) for n in db_names)
unit += function(admin, "admin_ssh_console_web_user_command_allowed")
unit += "\n".join(function(console, n) for n in console_names)
unit += (ROOT / "tests/admin_console_boundary/accounts.c").read_text()
account_tests = (ROOT / "tests/admin_console_boundary/accounts.c").read_text()
key_tests = (ROOT / "tests/admin_console_boundary/account_keys.c").read_text()
account_tests = account_tests.replace('int main(void)', key_tests + '\nint main(void)')
account_tests = account_tests.replace(' typed_account_tests();', ' typed_key_tests();\n typed_account_tests();')
assert ' typed_key_tests();' in account_tests
unit += account_tests
with tempfile.TemporaryDirectory(prefix="admin-accounts-") as directory:
path = Path(directory)
(path / "test.c").write_text(unit)
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror", "-Wno-unused-variable",
str(path / "test.c"), str(IDF / "components/console/split_argv.c"),
"-o", str(path / "test")], check=True, timeout=30)
"-lcrypto", "-o", str(path / "test")], check=True, timeout=30)
result = subprocess.run([str(path / "test")], check=True, timeout=10, capture_output=True, text=True)
assert "test-password" not in result.stdout
assert "Generated password for" not in result.stdout
print("PASS: canonical SSH keys: Ed25519/P256 parser and authorization, malformed/off-curve/truncated inputs, zero-wait fingerprints, stale ID/generation/recreation, duplicates/capacity, sparse indices, failed persistence and CLI parity (OpenSSL-backed curve/SHA adapters)")
print("PASS: operation-admission semantics: browser invalidated in derivation double before NVS; admitted add/password transactions still commit, only target is revoked, next command rejects; persistence failure still preserves live state (not precommit cancellation or real concurrency)")
print("PASS: canonical parsed accounts + production DB transactions: nonself isolation, prompt revocation/cancel/mismatch, currentness, persistence/RNG/derive failures, final-admin invariants, self/generated/key/recovery traps; no password output")