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
+136 -1
View File
@@ -41,6 +41,9 @@ static void check_slot_wiped(void) {
zero(s_operation.password,sizeof(s_operation.password)); assert(!s_operation.password_length);
zero(&s_operation.principal,sizeof(s_operation.principal));
zero(&s_operation.target,sizeof(s_operation.target));
zero(s_operation.key_blob,sizeof(s_operation.key_blob));
zero(s_operation.key_type,sizeof(s_operation.key_type));
assert(!s_operation.key_blob_length && !s_operation.key_index);
}
esp_err_t admin_ssh_console_submit_account_settings(uint32_t id) {
assert(!host_lock_depth && !dispatcher && id);
@@ -88,6 +91,34 @@ esp_err_t web_serial_transport_revoke_user(const uint8_t *u,size_t n) {
esp_err_t ssh_transport_revoke_user(const uint8_t *u,size_t n) {
assert(dispatcher && !host_lock_depth && n==5 && !memcmp(u,self_target ? "alice" : "carol",5)); ++ssh_revokes; return ESP_FAIL;
}
static esp_err_t keys_error;
static unsigned key_lists;
esp_err_t user_database_get_account_keys(const user_database_account_t *target, user_database_user_snapshot_t *out) {
assert(!dispatcher && !host_lock_depth && target->user_id==7 && target->auth_generation==2);
assert(!strcmp(target->username,"carol")); ++key_lists;
memset(out,0,sizeof(*out));
if (keys_error) return keys_error;
strcpy(out->username,target->username); out->user_id=target->user_id; out->auth_generation=target->auth_generation;
out->public_key_count=3;
for (unsigned i=0;i<3;++i) {
out->public_keys[i].active=true; out->public_keys[i].index=i;
strcpy(out->public_keys[i].key_type,i==0 ? "ssh-ed25519" : "ecdsa-sha2-nistp256");
memset(out->public_keys[i].sha256_fingerprint,i,32);
}
return ESP_OK;
}
esp_err_t user_database_add_ssh_key_current(const user_database_account_t *target,
const uint8_t *type,size_t type_length,const uint8_t *blob,size_t blob_length,uint8_t *index) {
assert(type_length==11 && !memcmp(type,"ssh-ed25519",11));
assert(blob_length==51 && blob[3]==11 && blob[18]==32); *index=0;
return user_database_delete_current(target);
}
esp_err_t user_database_remove_ssh_key_current(const user_database_account_t *target,uint8_t index) {
assert(index<3); return user_database_delete_current(target);
}
esp_err_t user_database_clear_ssh_keys_current(const user_database_account_t *target) {
return user_database_delete_current(target);
}
static const char deletion[]="{\"action\":\"delete\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2}";
static const char role_body[]="{\"action\":\"role\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"role\":\"admin\"}";
static void account_begin(const issued_t *identity,const char *body) {
@@ -311,6 +342,110 @@ static void self_tests(void) {
self_target=false;
puts("PASS Accounts self role/delete/password: protected failures keep login, success immediately target-revokes all logins, stale result reads denied, unrelated user survives");
}
static void key_tests(void) {
auth_reset(); issued_t admin=mint(&alice), user=mint(&bob); receive_fragment=768;
const char *selection="{\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2}";
for (unsigned mode=0;mode<13;++mode) {
account_begin(mode==0 ? NULL : mode==1 ? &user : &admin,selection);
req.uri="/api/settings/accounts/keys";
if (mode==2) req.method=HTTP_GET;
if (mode==3) req.uri="/api/settings/accounts/keys?x=1";
if (mode==4) add("Origin","https://evil.example");
if (mode==5) add("X-CSRF-Token","duplicate");
if (mode==6) add("Transfer-Encoding","chunked");
if (mode==7) add("Sec-Fetch-Site","cross-site");
if (mode==8) add("Content-Type","text/plain");
if (mode==9) req.content_len=aux.remaining_len=769;
if (mode==10) stale_user=alice.user_id;
if (mode==11) db_fail=true;
if (mode==12) receive_fragment=1;
unsigned before=key_lists; (void)web_account_keys_handler(&req);
assert(response_status[0]=='4' && key_lists==before);
zero(scratch,sizeof(scratch)); stale_user=0; db_fail=false; receive_fragment=768;
}
admin=mint(&alice);
for (unsigned mode=0;mode<3;++mode) {
account_begin(&admin,selection); req.uri="/api/settings/accounts/keys";
keys_error=mode==1 ? ESP_ERR_NOT_FOUND : mode==2 ? ESP_ERR_TIMEOUT : ESP_OK;
unsigned calls=mutations, ids=s_next_id;
assert(web_account_keys_handler(&req)==ESP_OK);
assert(!strcmp(response_status,mode==1 ? "409 Conflict" : mode==2 ? "503 Service Unavailable" : "200 OK"));
assert(calls==mutations && ids==s_next_id && strlen(output)<512);
if (!mode) {
assert(strstr(output,"\"index\":2") && strstr(output,"SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\""));
assert(strstr(output,"ecdsa-sha2-nistp256") && !strstr(output,"password") && !strstr(output,"blob"));
bool no_store=false;
for (unsigned i=0;i<aux.resp_hdrs_count;++i)
if (!strcmp(response_headers[i].field,"Cache-Control")) no_store=!strcmp(response_headers[i].value,"no-store");
assert(no_store);
}
}
keys_error=ESP_OK;
const char *bad_selection[]={"{}", "{\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"username\":\"carol\"}",
"{\"action\":\"key-clear\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2}",
"{\"username\":\"carol\",\"user_id\":7,\"auth_generation\":0}"};
for (unsigned i=0;i<sizeof(bad_selection)/sizeof(*bad_selection);++i) {
account_begin(&admin,bad_selection[i]); req.uri="/api/settings/accounts/keys";
assert(web_account_keys_handler(&req)==ESP_OK && !strcmp(response_status,"400 Bad Request"));
}
const char *ed="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
char body[800], text[500];
account_operation_t parsed={0};
assert(parse_public_key("ecdsa-sha2-nistp256 AAAA",24,&parsed));
assert(!strcmp(parsed.key_type,"ecdsa-sha2-nistp256"));
uint8_t maximum_blob[129]={0}; unsigned char encoded[177]; size_t encoded_length=0;
for (size_t n=128;n<=129;++n) {
assert(mbedtls_base64_encode(encoded,sizeof(encoded),&encoded_length,maximum_blob,n)==0);
snprintf(text,sizeof(text),"ssh-ed25519 %s",encoded);
memset(&parsed,0,sizeof(parsed));
assert(parse_public_key(text,strlen(text),&parsed)==(n==128));
}
snprintf(body,sizeof(body),"{\"action\":\"key-add\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"public_key\":\"%s comment\"}",ed);
submit_account(&admin,body); execute_account(); assert(s_operation.state==OK); check_slot_wiped();
const esp_err_t errors[]={USER_DATABASE_ERR_DUPLICATE_SSH_KEY,ESP_ERR_NO_MEM,ESP_ERR_NOT_FOUND,ESP_ERR_INVALID_ARG};
const unsigned states[]={DUPLICATE,FULL,STALE,FAILED};
for (unsigned i=0;i<4;++i) {
unsigned revokes=web_revokes; mutation_error=errors[i];
submit_account(&admin,body); execute_account(); assert(s_operation.state==states[i] && web_revokes==revokes);
}
mutation_error=ESP_OK;
snprintf(body,sizeof(body),"{\"action\":\"key-add\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"public_key\":\"%s\",\"public_key\":\"%s\"}",ed,ed);
account_begin(&admin,body); account_expect("400 Bad Request");
const char *invalid[]={"ssh-rsa AAAA", "-----BEGIN OPENSSH PRIVATE KEY-----", "ssh-ed25519 AAA", "ssh-ed25519 AA=A", "ssh-ed25519 AB==", "ssh-ed25519 AAAA\\ncomment", "ssh-ed25519 AAAA\\u0000", "ssh-ed25519 AAAA\\u0080", "ssh-ed25519 !!!!", "ssh-ed25519 ====", "ssh-ed25519 AAAA AAAA\\rAAAA"};
for (unsigned i=0;i<sizeof(invalid)/sizeof(*invalid);++i) {
snprintf(body,sizeof(body),"{\"action\":\"key-add\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"public_key\":\"%s\"}",invalid[i]);
account_begin(&admin,body); account_expect("400 Bad Request");
}
for (unsigned n=384;n<=385;++n) {
strcpy(text,ed); size_t size=strlen(text); memset(text+size,' ',n-size); text[n]=0;
snprintf(body,sizeof(body),"{\"action\":\"key-add\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"public_key\":\"%s\"}",text);
account_begin(&admin,body); account_expect(n==384 ? "202 Accepted" : "400 Bad Request");
if (n==384) { execute_account(); assert(s_operation.state==OK); }
}
for (unsigned i=0;i<4;++i) {
snprintf(body,sizeof(body),"{\"action\":\"key-delete\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"key_index\":%u}",i);
account_begin(&admin,body); account_expect(i<3 ? "202 Accepted" : "400 Bad Request");
if (i<3) { execute_account(); assert(s_operation.state==OK); }
}
const char *bad[]={
"{\"action\":\"key-delete\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2}",
"{\"action\":\"key-delete\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"key_index\":00}",
"{\"action\":\"key-delete\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"key_index\":-1}",
"{\"action\":\"key-delete\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"key_index\":0,\"key_index\":1}",
"{\"action\":\"key-clear\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"key_index\":0}"};
for (unsigned i=0;i<sizeof(bad)/sizeof(*bad);++i) { account_begin(&admin,bad[i]); account_expect("400 Bad Request"); }
const char *clear="{\"action\":\"key-clear\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2}";
submit_account(&admin,clear); unsigned calls=mutations; s_operation.deadline=0; execute_account();
assert(s_operation.state==CANCELLED && mutations==calls);
submit_account(&admin,clear); web_session_store_invalidate(admin.view.id); execute_account();
assert(s_operation.state==CANCELLED && mutations==calls);
admin=mint(&alice); submit_account(&admin,clear); execute_account(); assert(s_operation.state==OK);
self_target=true;
submit_account(&admin,"{\"action\":\"key-clear\",\"username\":\"alice\",\"user_id\":7,\"auth_generation\":2}");
execute_account(); assert(s_operation.state==OK);
account_begin(&admin,NULL); account_expect("401 Unauthorized"); self_target=false;
puts("PASS Accounts keys: strict envelopes/schemas/limits, bounded fingerprint-only POST read, HTTP policy, dispatcher results, cancellation and self revocation uncertainty");
}
static void account_settings_tests(void) {
auth_reset(); issued_t admin=mint(&alice), user=mint(&bob), other=mint(&alice); receive_fragment=64;
account_begin(NULL,deletion); account_expect("401 Unauthorized");
@@ -372,6 +507,6 @@ static void account_settings_tests(void) {
submit_account(&admin,deletion); stale_user=alice.user_id; execute_account(); stale_user=0;
assert(s_operation.state==CANCELLED && mutations==before);
puts("PASS Accounts execution failure/stale/protected results, dequeue cancellation, missed account revocation/DB failure and admitted-work completion after expiry");
credential_tests(); password_parser_tests(); generated_tests(); self_tests();
credential_tests(); password_parser_tests(); generated_tests(); self_tests(); key_tests();
assert(wiped_passwords && wiped_bodies && wiped_generated && wiped_responses);
}
+21
View File
@@ -47,6 +47,27 @@ settings = "--settings" in sys.argv
serial_settings = "--serial-settings" in sys.argv
accounts = "--accounts" in sys.argv
if accounts:
HEADERS["mbedtls/base64.h"] = """
#pragma once
#include <stddef.h>
#include <openssl/evp.h>
static inline int mbedtls_base64_decode(unsigned char *out, size_t capacity, size_t *length,
const unsigned char *in, size_t n) {
unsigned char decoded[132];
if (!n || n>172 || n%4) return -1;
int result=EVP_DecodeBlock(decoded,in,(int)n);
if (result<0) return -1;
if (in[n-1]=='=') --result;
if (in[n-2]=='=') --result;
if ((size_t)result>capacity) return -1;
memcpy(out,decoded,(size_t)result); *length=(size_t)result; return 0;
}
static inline int mbedtls_base64_encode(unsigned char *out, size_t capacity, size_t *length,
const unsigned char *in, size_t n) {
if (capacity < 4*((n+2)/3)+1) return -1;
*length=(size_t)EVP_EncodeBlock(out,in,(int)n); return 0;
}
"""
HEADERS["esp_timer.h"] += """
#include <stdbool.h>
typedef void *esp_timer_handle_t;