feat(ssh): add rio command to generate ssh certificates#503
feat(ssh): add rio command to generate ssh certificates#503rrkumarshikhar wants to merge 1 commit intodevelfrom
Conversation
|
🤖 Pull Request Artifacts (#24143739168) 🎉 |
9ff9b2c to
c2831f1
Compare
There was a problem hiding this comment.
Pull request overview
Adds a new rio ssh CLI command to request a signed SSH user certificate via the v2 SDK, write it next to the user’s SSH key, and optionally load it into ssh-agent for immediate use (issue #1550).
Changes:
- Introduces
riocli.sshcommand implementation plus supporting utilities for key discovery, cert writing, agent loading, and expiry parsing. - Wires the new command into the main CLI bootstrap so it’s available as
rio ssh. - Adds unit/integration-style tests for the new utilities and CLI flow, and updates dependencies to consume a specific SDK v2 Git revision.
Reviewed changes
Copilot reviewed 5 out of 6 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
riocli/ssh/__init__.py |
Implements the rio ssh Click command: discover/read key, call SDK signing API, write cert, optionally add to ssh-agent. |
riocli/ssh/util.py |
Adds helper functions for SSH key discovery, certificate path derivation, file writing, ssh-agent interaction, and expiry parsing. |
riocli/bootstrap.py |
Registers the new ssh command with the root CLI. |
tests/main/test_ssh.py |
Adds tests for utility functions and main CLI behaviors (happy path, flags, and error handling). |
pyproject.toml |
Switches rapyuta-io-sdk-v2 dependency to a specific Git commit URL. |
uv.lock |
Updates the lockfile to reflect the Git-sourced rapyuta-io-sdk-v2 dependency. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
7ec1154 to
fd54468
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 6 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
| "waiting>=1.4.1", | ||
| "yaspin==2.5.0", | ||
| "ansible-core>=2.13.13", | ||
| "rapyuta-io-sdk-v2>=0.5.0", | ||
| "rapyuta-io-sdk-v2 @ git+https://github.com/rapyuta-robotics/rapyuta-io-sdk-v2.git@8cd72e0a34d54d66990cc90109b232ec28ceefa9", | ||
| "typing-extensions>=4.15.0", | ||
| "python-benedict==0.30", | ||
| ] |
03450af to
14626fb
Compare
14626fb to
68714ac
Compare
68714ac to
50a2912
Compare
| # 9. valid_before (uint64) — this is what we want | ||
| valid_before, _ = _read_uint64(cert_blob, pos) | ||
|
|
||
| return datetime.fromtimestamp(valid_before, tz=timezone.utc) | ||
|
|
||
| except Exception: | ||
| return None |
There was a problem hiding this comment.
parse_cert_valid_before() blindly converts the valid_before uint64 to a Python datetime via fromtimestamp(). OpenSSH certificates can encode “never expires” as valid_before = 0xFFFFFFFFFFFFFFFF, which will overflow fromtimestamp() and currently causes parsing to return None (and the cert to be treated as invalid). Consider explicitly handling the sentinel value (e.g., return datetime.max or a dedicated sentinel and have is_cert_valid() treat it as valid).
| message = struct.pack(">IB", len(payload) + 1, msg_type) + payload | ||
| sock.sendall(message) | ||
|
|
||
| resp_len = struct.unpack(">I", _recv_all(sock, 4))[0] |
There was a problem hiding this comment.
_send_recv() assumes the agent response length is at least 1 byte (for the response type) and immediately indexes resp[0]. If the agent returns an empty response (or the connection is truncated), this will raise an IndexError rather than a clear RuntimeError. Add an explicit guard for resp_len < 1 / empty resp and raise a protocol/connection error instead.
| resp_len = struct.unpack(">I", _recv_all(sock, 4))[0] | |
| resp_len = struct.unpack(">I", _recv_all(sock, 4))[0] | |
| if resp_len < 1: | |
| raise RuntimeError("SSH agent returned an empty response") |
| private_key = load_ssh_private_key( | ||
| private_key_path.read_bytes(), | ||
| password=None, | ||
| ) | ||
|
|
||
| raw_pk = private_key.public_key().public_bytes( | ||
| encoding=serialization.Encoding.Raw, | ||
| format=serialization.PublicFormat.Raw, | ||
| ) | ||
| raw_seed = private_key.private_bytes( | ||
| encoding=serialization.Encoding.Raw, | ||
| format=serialization.PrivateFormat.Raw, | ||
| encryption_algorithm=serialization.NoEncryption(), | ||
| ) | ||
|
|
There was a problem hiding this comment.
_get_ed25519_raw_keys() implicitly assumes the private key at private_key_path is an unencrypted ed25519 key. If the file is missing, encrypted, or a different key type, load_ssh_private_key() / public_bytes(...Raw...) will raise cryptography exceptions that bubble up with low-level messages. Consider catching these cases and raising a RuntimeError with a clear, user-facing explanation (and validate that raw_seed and raw_pk are 32 bytes each before building the agent payload).
| private_key = load_ssh_private_key( | |
| private_key_path.read_bytes(), | |
| password=None, | |
| ) | |
| raw_pk = private_key.public_key().public_bytes( | |
| encoding=serialization.Encoding.Raw, | |
| format=serialization.PublicFormat.Raw, | |
| ) | |
| raw_seed = private_key.private_bytes( | |
| encoding=serialization.Encoding.Raw, | |
| format=serialization.PrivateFormat.Raw, | |
| encryption_algorithm=serialization.NoEncryption(), | |
| ) | |
| try: | |
| private_key_data = private_key_path.read_bytes() | |
| except OSError as exc: | |
| raise RuntimeError( | |
| f"Failed to read SSH private key: {private_key_path}" | |
| ) from exc | |
| try: | |
| private_key = load_ssh_private_key( | |
| private_key_data, | |
| password=None, | |
| ) | |
| raw_pk = private_key.public_key().public_bytes( | |
| encoding=serialization.Encoding.Raw, | |
| format=serialization.PublicFormat.Raw, | |
| ) | |
| raw_seed = private_key.private_bytes( | |
| encoding=serialization.Encoding.Raw, | |
| format=serialization.PrivateFormat.Raw, | |
| encryption_algorithm=serialization.NoEncryption(), | |
| ) | |
| except (TypeError, ValueError) as exc: | |
| raise RuntimeError( | |
| "SSH private key must be an unencrypted Ed25519 private key: " | |
| f"{private_key_path}" | |
| ) from exc | |
| if len(raw_seed) != 32 or len(raw_pk) != 32: | |
| raise RuntimeError( | |
| "SSH private key did not produce valid Ed25519 raw key material " | |
| f"(expected 32-byte seed and public key): {private_key_path}" | |
| ) |
| try: | ||
| if public_key_path.is_file(): | ||
| pk_blob = _get_key_blob(public_key_path) | ||
| remove_identity(pk_blob) | ||
| remove_identity(pk_blob) | ||
| except Exception: | ||
| pass |
There was a problem hiding this comment.
remove_rio_identities() currently wraps everything in except Exception: pass, which can mask unexpected programmer errors (and can make failures very hard to debug). It would be safer to only suppress the specific, expected failures here (e.g., agent unreachable, invalid/missing key file, base64 decode issues) and let other exceptions surface.
https://github.com/rapyuta-robotics/rapyuta_io/issues/1550