Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

crates/gunnar-ui-server/proto/gunnar/v1/access.proto

syntax = "proto3";

package gunnar.v1;

import "gunnar/v1/common.proto";

// Principals, keys and grants: gunnar's own identity table.
//
// ⚠ Every RPC here reads or MUTATES who may reach which repository over SSH.
// This is how the key table itself is written. Since the allow-all hole was
// closed, every one of them needs an `sshsig` credential from a key in
// THIS table plus an Admin grant from the same ACL the SSH listener uses, and
// the default bind is still loopback on top of that. Git traffic is unaffected
// either way: it runs on the separate SSH listener with publickey auth.
service Access {
  rpc AddPrincipal(AddPrincipalRequest) returns (AddPrincipalResponse);
  rpc ListPrincipals(ListPrincipalsRequest) returns (ListPrincipalsResponse);
  rpc RemovePrincipal(RemovePrincipalRequest) returns (RemovePrincipalResponse);
  // Change a principal's display NAME and nothing else. The id — and with it
  // the account namespace, every key, grant, membership and gate — stays where
  // it is. A caller that pinned the id sees the same principal afterwards.
  rpc RenamePrincipal(RenamePrincipalRequest) returns (RenamePrincipalResponse);

  rpc AddKey(AddKeyRequest) returns (AddKeyResponse);
  rpc RevokeKey(RevokeKeyRequest) returns (RevokeKeyResponse);
  rpc ListKeys(ListKeysRequest) returns (ListKeysResponse);

  rpc Grant(GrantRequest) returns (GrantResponse);
  rpc Revoke(RevokeRequest) returns (RevokeResponse);
  // A server admin's — and, for a request that names a namespace, that
  // namespace's ACCOUNT ADMIN's, answered for that namespace only (2026-10-07,
  // the members model: the account admin sees who may read and write the
  // account's repositories, and hands out Read/Write with Grant/Revoke above).
  rpc ListGrants(ListGrantsRequest) returns (ListGrantsResponse);

  // Groups (punkt 9). A group is a principal (AddPrincipal with kind GROUP);
  // these manage its membership. A member inherits every grant the group holds,
  // so adding a member is as powerful as a grant and takes Admin the same way.
  //
  // Add and Remove each carry an `sshsig` the acting admin made with their own
  // key: the change is not the server's word for it but the admin's, evidence a
  // third party can check with `ssh-keygen -Y verify`. The server verifies it,
  // applies the change to the live table, and appends it to the signed
  // membership log. `ListGroupMembershipEvents` reads that log back, which is
  // what makes "who was a member at time T" answerable from a signature and not
  // from a mutable row.
  rpc AddGroupMember(AddGroupMemberRequest) returns (AddGroupMemberResponse);
  rpc RemoveGroupMember(RemoveGroupMemberRequest) returns (RemoveGroupMemberResponse);
  rpc ListGroupMembers(ListGroupMembersRequest) returns (ListGroupMembersResponse);
  rpc ListGroupMembershipEvents(ListGroupMembershipEventsRequest)
      returns (ListGroupMembershipEventsResponse);

  // **Who am I, and may I?** (2026-10-04, the thin layer.) Both answer for the
  // CALLER only — there is no principal to name, so neither is a
  // reconnaissance tool — and neither needs any grant beyond being an
  // authenticated caller. A console asks them AS THE PERSON to draw only the
  // controls gunnar would honour; every verb still decides for itself.
  rpc WhoAmI(WhoAmIRequest) returns (WhoAmIResponse);
  // The same decision every verb's own check makes — grants, groups, account
  // ownership, the account gate, a narrowed principal's list — asked without
  // acting. `Check{namespace: N, level: ADMIN}` is exactly the account-admin
  // rule `Repos.SetVisibility`, `Repos.SetNamespaceSettings` and
  // `Accounts.EnrollEmployee` apply.
  rpc Check(CheckRequest) returns (CheckResponse);

  // **Web credentials: a person's passkeys, on their principal** (Stage L,
  // 2026-10-04). gunnar stores them beside the person's SSH keys — in the
  // identity table the twin replicates — so a console keeps only cookie
  // sessions, and a reinstalled or restored front keeps every login. A server
  // admin, or the principal itself (the self-scope), adds, lists and revokes;
  // only a USER may hold one: no human credential on a machine principal.
  rpc AddCredential(AddCredentialRequest) returns (AddCredentialResponse);
  rpc ListCredentials(ListCredentialsRequest) returns (ListCredentialsResponse);
  // Revoking a passkey also ends every identity-core session of its person:
  // the person's live console sessions end.
  // The LAST passkey held by any server-Admin person is refused
  // (FAILED_PRECONDITION): the box must keep one admin who can log in.
  rpc RevokeCredential(RevokeCredentialRequest) returns (RevokeCredentialResponse);
  // Change a passkey's label: its own principal, or a server admin.
  rpc RenameCredential(RenameCredentialRequest) returns (RenameCredentialResponse);

  // **The caller's encryption key** (CRYPT-YUBI, 2026-10-26): ONE key pair
  // per person, for every encrypted repository they are a member of. gunnar
  // keeps its PUBLIC half (what members wrap a repository's key ring to,
  // handed out by `Repos.RecipientKeys`) and its private half only as
  // ciphertext: one armored age file per UNLOCK METHOD — an SSH key, a
  // security key (WebAuthn PRF / FIDO2 hmac-secret), an age1pq1… key, a
  // printed recovery code. gunnar never holds a method's secret. The caller's
  // own key only (the self-scope); nobody, an admin neither, reads or changes
  // another person's methods. Adding or removing a method touches no
  // repository.
  rpc GetEncryptionKey(GetEncryptionKeyRequest) returns (GetEncryptionKeyResponse);
  // The first time: the public half and the first method's wrap. Refused
  // (ALREADY_EXISTS) when the caller has a key: a new key would orphan every
  // repository ring wrapped to the old one.
  rpc CreateEncryptionKey(CreateEncryptionKeyRequest) returns (CreateEncryptionKeyResponse);
  // One more method (made where another method was open: the wrap holds the
  // same key). Refused for a method already held (the same kind and ref).
  rpc AddUnlockMethod(AddUnlockMethodRequest) returns (AddUnlockMethodResponse);
  // Delete one method's wrap. The LAST method is refused (FAILED_PRECONDITION):
  // without one, nothing opens the key again.
  rpc RemoveUnlockMethod(RemoveUnlockMethodRequest) returns (RemoveUnlockMethodResponse);

  // **A first-login link for an existing person** (IDENTITY-HOST,
  // 2026-10-27): sign-up, passkey login, invites, sessions and step-up are the
  // identity core's (`nornir.identity.v1.IdentityService`, served beside this
  // service); this is the one door to it on a server admin's authority (the
  // console relaying a paid sign-up, an operator's `gunnar control invite`).
  // The core mints the link and keeps only its hash; the secret is shown here
  // once and redeems through `IdentityService.RedeemStart`/`RedeemFinish` onto
  // THIS principal. Only a person; `first_only` refuses one who already holds
  // a passkey (FAILED_PRECONDITION).
  rpc EnrolmentLink(EnrolmentLinkRequest) returns (EnrolmentLinkResponse);
  // **A fresh challenge for a machine's enrolment ceremony**
  // (`RequestMachineEnrolment`): 32 random bytes, single use, five minutes, at
  // most 16 open per client. A server admin's (the relay).
  rpc MachineChallenge(MachineChallengeRequest) returns (MachineChallengeResponse);

  // **A machine asks for a web credential; an admin approves it** (2026-10-05,
  // the ops kit's console credential without a copied session cookie). The
  // machine — an ops kit — makes its own key pair, answers a creation
  // ceremony with it, and asks for a role (ADMIN or a console's operator,
  // WRITE) under a machine name (`kit-*`, reserved for machines). The request
  // is PENDING and does nothing until a person who is a server admin approves
  // it from a session they STEPPED UP (a fresh, user-verified touch of their
  // own passkey, the identity core's), or refuses it. On approval gunnar makes the MACHINE
  // principal (never a person's credential set), grants it the role and stores
  // the credential, in one update. A request lives fifteen minutes; a short
  // code (`ABC-123`) is shown to the machine and beside the request, so the
  // admin approves the request the machine at their terminal made. Every
  // step is audited. This is the ONLY door to an admin credential on a
  // machine: an invite never makes an admin, and the installer's break-glass
  // token never approves one — it is the OWNER's first invite (the operator's
  // first passkey): he registers first, then approves the kit.
  //
  // Asked for by the relay (a server admin: the console), anonymously on the
  // machine's behalf, over a `MachineChallenge`; the attestation is checked.
  rpc RequestMachineEnrolment(RequestMachineEnrolmentRequest)
      returns (RequestMachineEnrolmentResponse);
  // One request's state, by its id: the relay polls it for the machine.
  rpc MachineEnrolmentStatus(MachineEnrolmentStatusRequest)
      returns (MachineEnrolmentStatusResponse);
  // The pending requests: a server admin's.
  rpc ListMachineEnrolments(ListMachineEnrolmentsRequest)
      returns (ListMachineEnrolmentsResponse);
  // Approve: a PERSON who is a server admin, from their identity-core session
  // stepped up within the window (`[stepup]` otherwise). Nothing else
  // approves: not a machine (an approved kit), not a signed key, and not the
  // break-glass token (the owner's first invite).
  rpc ApproveMachineEnrolment(ApproveMachineEnrolmentRequest)
      returns (ApproveMachineEnrolmentResponse);
  // Refuse: a server admin. Nothing is stored.
  rpc RefuseMachineEnrolment(RefuseMachineEnrolmentRequest)
      returns (RefuseMachineEnrolmentResponse);
}

enum MachineEnrolmentState {
  MACHINE_ENROLMENT_STATE_UNSPECIFIED = 0;
  MACHINE_ENROLMENT_STATE_PENDING = 1;
  MACHINE_ENROLMENT_STATE_APPROVED = 2;
  MACHINE_ENROLMENT_STATE_REFUSED = 3;
  MACHINE_ENROLMENT_STATE_EXPIRED = 4;
}

// One machine's enrolment request, never its key.
message MachineEnrolment {
  // Random, 32 hex digits: what the machine polls with.
  string request_id = 1;
  // The machine principal it becomes (`kit-*`).
  string machine = 2;
  // What the machine calls itself ("installer kit on dwarf").
  string label = 3;
  // ADMIN, or WRITE (a console's operator).
  GrantLevel server_level = 4;
  // Shown to the machine and beside the request: `ABC-123`.
  string code = 5;
  // `SHA256:…` of the credential's public key (SEC1), for the eye.
  string key_fingerprint = 6;
  int64 requested_at_unix_ms = 7;
  int64 expires_at_unix_ms = 8;
  MachineEnrolmentState state = 9;
  // Who approved or refused it (a principal id, or "the break-glass token").
  string decided_by = 10;
  int64 decided_at_unix_ms = 11;
  // The credential id the machine registered.
  bytes credential_id = 12;
}

message RequestMachineEnrolmentRequest {
  // `kit-` and then [a-z0-9-], at most 64 characters in all.
  string machine = 1;
  string label = 2;
  // ADMIN, WRITE or READ. UNSPECIFIED asks for the lowest, READ (2026-10-18):
  // the approving admin grants more.
  GrantLevel server_level = 3;
  bytes credential_id = 4;
  // P-256, SEC1 uncompressed (0x04 || X || Y).
  bytes public_key = 5;
  // A `webauthn.create` ceremony over a `MachineChallenge`.
  bytes client_data_json = 6;
  string rp_id = 7;
  string origin = 8;
  uint32 sign_count = 9;
  // The browser's `attestationObject`, byte for byte — checked as the
  // attestation of the key it names (additive, 2026-10-10, stinky row 13).
  bytes attestation_object = 10;
}
message RequestMachineEnrolmentResponse {
  MachineEnrolment request = 1;
}

message MachineEnrolmentStatusRequest {
  string request_id = 1;
}
message MachineEnrolmentStatusResponse {
  MachineEnrolment request = 1;
}

message ListMachineEnrolmentsRequest {}
message ListMachineEnrolmentsResponse {
  // Pending only, oldest first.
  repeated MachineEnrolment requests = 1;
}

message ApproveMachineEnrolmentRequest {
  string request_id = 1;
  // The code shown beside the request: the approval is for THAT request.
  string code = 2;
  // Was the approver's step-up assertion; the session's step-up is the core's.
  reserved 3;
  // The role the approver GIVES (2026-10-18): ADMIN, WRITE or READ.
  // UNSPECIFIED keeps the role the request asked for. A request that names
  // no role asks for the lowest (READ); the admin grants more here.
  GrantLevel server_level = 4;
}
message ApproveMachineEnrolmentResponse {
  MachineEnrolment request = 1;
  // The machine principal, as gunnar now holds it.
  Principal principal = 2;
  WebCredential credential = 3;
}

message RefuseMachineEnrolmentRequest {
  string request_id = 1;
}
message RefuseMachineEnrolmentResponse {
  MachineEnrolment request = 1;
}

message WhoAmIRequest {}
message WhoAmIResponse {
  // The principal the credential on this call belongs to.
  Principal principal = 1;
  // The fingerprint of the key that signed this call.
  string fingerprint = 2;
  // The highest SERVER-wide level the caller holds right now; UNSPECIFIED when
  // it holds none (and while its account is locked out).
  GrantLevel server_level = 3;
  // **The account the caller acts in** (2026-10-07, the members model: a
  // customer is an ACCOUNT that pays; its members use its space and never
  // buy). The caller's own account when it holds one, the account that
  // enrolled it when it is a member; empty when neither (a machine, a
  // principal with no namespace).
  string account = 4;
  // What the caller is to that account: "admin" (its own account, which has
  // enrolled members or bought seats), "individual" (its own account, no
  // members), "employee" (a member the account's admin enrolled: it uses the
  // account's room and buys nothing), or "" with no account. A console reads
  // it to draw only what this person may do; every verb still decides
  // (`Billing.MintActorTicket` refuses a member by name).
  string account_role = 5;
  // The display name of that account's admin — whom a member asks for more
  // room. Empty with no account.
  string account_admin = 6;
}

message CheckRequest {
  // Empty: the server itself.
  string namespace = 1;
  // Empty: the whole namespace. A repo needs its namespace.
  string repo = 2;
  // READ, WRITE or ADMIN.
  GrantLevel level = 3;
}
message CheckResponse {
  bool allowed = 1;
  // When not allowed: the sentence the verb would refuse with. Empty when
  // allowed.
  string reason = 2;
}

// add or remove -- the change bound into the signature, so it cannot be
// replayed as the other.
enum MembershipAction {
  MEMBERSHIP_ACTION_UNSPECIFIED = 0;
  MEMBERSHIP_ACTION_ADD = 1;
  MEMBERSHIP_ACTION_REMOVE = 2;
}

// One change to one group's membership, exactly as it was signed.
message MembershipEvent {
  // The group whose membership changed (a principal of kind GROUP).
  string group = 1;
  // The principal added to or removed from it (another group is nesting).
  string member = 2;
  MembershipAction action = 3;
  // The identity-table principal that made the change -- the acting admin. Inside the
  // signature and checked against the caller, so it cannot be filed in another
  // admin's name.
  string actor = 4;
  int64 at_unix_ms = 5;
}

// A `MembershipEvent` with the signature that makes it evidence.
message SignedMembershipEvent {
  MembershipEvent event = 1;
  // `SHA256:…` of the actor's key.
  string key_fingerprint = 2;
  // The `sshsig`, PEM-armoured.
  string signature = 3;
}

// A principal has an ID and a NAME, and they are two different things.
//
// The id is the identity: the account namespace this principal intrinsically
// owns, the key every grant, membership and gate is filed under, what a policy
// file names. It is chosen once, here, and never moves. The name is a label
// for humans: unique on the server (against every other name AND every other
// id, so a listing never shows two rows a reader takes for one), and free to
// change with `RenamePrincipal` without anything keyed on the id noticing.
//
// A client that pins a principal pins the `id` from the response — never the
// name it asked for — and may pin `created_at_unix_ms` beside it: an account
// slug is claimable again once its account is purged, and the stamp is what
// tells a recreation under the same id from the row first seen.
message AddPrincipalRequest {
  // The display name. Required.
  string name = 1;
  PrincipalKind kind = 2;
  // The id, and therefore the account namespace. Same character rules as a
  // namespace. **Empty means "the same as `name`"**, which is what every
  // caller before this field existed got, so nothing has to change to keep
  // working; set it to give a principal an account slug that is not its name.
  string id = 3;
}
message AddPrincipalResponse {
  Principal principal = 1;
}

message ListPrincipalsRequest {
  uint32 page_size = 1;
  string page_token = 2;
  // When set, only the principal whose display name is exactly this: one
  // probe on the server's name index rather than a listing of the table. The
  // response holds one principal or none.
  string name = 3;
}
message ListPrincipalsResponse {
  repeated Principal principals = 1;
  string next_page_token = 2;
  // **When each principal last signed in** (additive, 2026-10-21): principal
  // id -> Unix ms of the last console session issued to it (a passkey login,
  // an SSO login). A principal with no entry has never signed in on this box
  // (or not since it started keeping the stamp). Minute-grained.
  map<string, int64> last_sign_in_unix_ms = 3;
  // **When each principal last changed something** (additive, 2026-10-21):
  // principal id -> Unix ms of its last authenticated call that changed state
  // (a push that moved a reference, a control-plane call that is not a read).
  // A read is not an operation. No entry: never. Minute-grained.
  map<string, int64> last_operation_unix_ms = 4;
}

message RenamePrincipalRequest {
  // The principal, by id. Ids do not change; that is the point.
  string principal_id = 1;
  // The new display name. Refused (ALREADY_EXISTS) if another principal
  // carries it as its name or as its id.
  string name = 2;
}
message RenamePrincipalResponse {
  // The principal as it now is: same id, same `created_at_unix_ms`, new name.
  Principal principal = 1;
}

message RemovePrincipalRequest {
  string principal_id = 1;
}
message RemovePrincipalResponse {
  // Removing a principal revokes its keys and grants with it.
  uint32 keys_revoked = 1;
  uint32 grants_revoked = 2;
}

// What the target principal attests when a key is added to it: *this key may
// act as me*. Signed with a key the principal ALREADY holds, under the sshsig
// namespace `gunnar-key-attestation` — its own, so it can never be replayed as
// a membership change or a forge approval, and `ssh-keygen -Y verify -n
// gunnar-key-attestation` checks it with no trust in the server.
message KeyAttestation {
  // The principal gaining the key — the attester.
  string principal_id = 1;
  // `SHA256:…` of the key being ADDED. The server derives it from the key
  // line in the request and refuses an attestation that names another.
  string fingerprint = 2;
  int64 at_unix_ms = 3;
}
message SignedKeyAttestation {
  KeyAttestation attestation = 1;
  // `SHA256:…` of the ATTESTING key — one the principal holds.
  string key_fingerprint = 2;
  // The `sshsig`, PEM-armoured.
  string signature = 3;
}

message AddKeyRequest {
  string principal_id = 1;
  // One line in OpenSSH authorized_keys format: "ssh-ed25519 AAAA... comment".
  // The server parses it and derives the sha256 fingerprint itself. The
  // client never supplies a fingerprint, because a fingerprint it chose would
  // not be one the SSH listener could ever match.
  string openssh_public_key = 2;
  string comment = 3;
  optional int64 expires_at_unix_ms = 4;
  // **The target principal's own word that this key may act as them.**
  //
  // The caller's credential proves who ASKED; this proves the person whose
  // access grows agreed, so an admin cannot silently mint access as another
  // person. Required exactly when the target already holds a key able to
  // sign AND the caller is not the target: a principal with no signing key
  // has nobody to ask (that is creation — `user add`, bootstrap, a console's
  // first enrolment), and a caller who IS the target has already signed the
  // call with one of their keys. Refused (FAILED_PRECONDITION) when required
  // and absent; INVALID_ARGUMENT when present and wrong: another subject, a
  // key the target does not hold, or a signature that does not verify.
  SignedKeyAttestation attestation = 5;
}
message AddKeyResponse {
  PublicKey key = 1;
}

message RevokeKeyRequest {
  string fingerprint = 1;
}
message RevokeKeyResponse {}

message ListKeysRequest {
  // Empty lists every key on the server.
  string principal_id = 1;
}
message ListKeysResponse {
  repeated PublicKey keys = 1;
}

// Scope is NAMESPACE-wide when `repo` is empty, otherwise a single repo.
message GrantRequest {
  string principal_id = 1;
  string namespace = 2;
  string repo = 3;
  GrantLevel level = 4;
}
message GrantResponse {
  Grant grant = 1;
}

message RevokeRequest {
  string principal_id = 1;
  string namespace = 2;
  string repo = 3;
}
message RevokeResponse {}

message ListGrantsRequest {
  // Each filter is ignored when empty; all supplied filters must match. A
  // caller who is no server admin must name a namespace it administers.
  string principal_id = 1;
  string namespace = 2;
  string repo = 3;
}
message ListGrantsResponse {
  repeated Grant grants = 1;
}

// `group_id` must name a principal of kind GROUP; `member_id` any principal
// (another group is allowed — that is nesting).
//
// `signed` is the acting admin's `sshsig` over this exact change. It is
// required, and its `event.group`/`event.member` must equal the fields below
// (a cross-check, so an event cannot be signed for one member and applied to
// another) and its `event.action` must be ADD. Its `event.actor` must be the
// caller. The signature is verified against the identity key table before anything is
// applied, and appended to the membership log after.
message AddGroupMemberRequest {
  string group_id = 1;
  string member_id = 2;
  SignedMembershipEvent signed = 3;
}
message AddGroupMemberResponse {
  // Where in the membership log the signed event landed.
  uint64 index = 1;
}

message RemoveGroupMemberRequest {
  string group_id = 1;
  string member_id = 2;
  // The acting admin's `sshsig` over this exact removal; `event.action` must be
  // REMOVE. Verified and appended exactly as the add above.
  SignedMembershipEvent signed = 3;
}
message RemoveGroupMemberResponse {
  // False when the principal was not a member; the call still succeeds.
  bool was_member = 1;
  // Where in the membership log the signed event landed. A removal of a
  // non-member is still recorded: the attempt is evidence too.
  uint64 index = 2;
}

message ListGroupMembersRequest {
  string group_id = 1;
}
message ListGroupMembersResponse {
  // The DIRECT members of the group, by principal id.
  repeated Principal members = 1;
}

// The signed membership log, the evidence behind the live table.
message ListGroupMembershipEventsRequest {
  // Empty returns every membership event on the server; otherwise only this
  // group's, in the order they were appended.
  string group_id = 1;
}
message ListGroupMembershipEventsResponse {
  repeated SignedMembershipEvent events = 1;
}

// One web credential (a WebAuthn passkey). The public key is not on the wire:
// it is gunnar's to verify with, and nobody's to read back.
message WebCredential {
  // The authenticator's raw credential id — what the browser echoes at login.
  bytes credential_id = 1;
  string principal_id = 2;
  // What the person called it ("work laptop", "YubiKey").
  string label = 3;
  int64 created_at_unix_ms = 4;
  // Absent until it has been used to log in.
  optional int64 last_used_unix_ms = 5;
  // The authenticator's signature counter as of the last accepted login.
  uint32 sign_count = 6;
}

message AddCredentialRequest {
  string principal_id = 1;
  bytes credential_id = 2;
  // The credential's P-256 public key, SEC1 UNCOMPRESSED: 0x04 || X || Y, 65
  // bytes — the COSE key's x and y (ES256) with the 0x04 prefix. Refused if it
  // is not a point on the curve.
  bytes public_key = 3;
  string label = 4;
  // The counter the registration reported (often 0).
  uint32 sign_count = 5;
}
message AddCredentialResponse {
  WebCredential credential = 1;
}

message ListCredentialsRequest {
  // Your own: the self-scope. Anybody else's, or every credential (empty): a
  // server admin's.
  string principal_id = 1;
}
message ListCredentialsResponse {
  repeated WebCredential credentials = 1;
}

message RevokeCredentialRequest {
  bytes credential_id = 1;
}
message RevokeCredentialResponse {}

message RenameCredentialRequest {
  bytes credential_id = 1;
  // The new label: not empty, at most 128 characters.
  string label = 2;
}
message RenameCredentialResponse {
  WebCredential credential = 1;
}

// One unlock method of a person's encryption key: what opens it, and the key
// wrapped to it (ciphertext to gunnar).
message UnlockMethod {
  // Set by gunnar: 16 hex characters of SHA-256 over `wrap`.
  string id = 1;
  // "ssh", "security-key", "age-pq" or "recovery-code".
  string kind = 2;
  // The person's own words ("YubiKey 5C, keyring").
  string label = 3;
  // What names the method: the SSH public key line; a security key's
  // credential id (base64url, no padding); the age1pq1… key; empty for a
  // recovery code.
  string method_ref = 4;
  // A security key: the WebAuthn relying party id its credential is bound to
  // (the console's host), which the CLI's FIDO2 hmac-secret call names too.
  string rp_id = 5;
  // The person's encryption key, wrapped to this method: an armored age file
  // with exactly one recipient stanza of the method's kind.
  string wrap = 6;
  // When gunnar took it (Unix ms).
  uint64 added_ms = 7;
}
message GetEncryptionKeyRequest {}
message GetEncryptionKeyResponse {
  // False: the caller has no encryption key yet (`public_key` empty).
  bool exists = 1;
  // "age1…" (or "age1pq1…" for a quantum-safe key).
  string public_key = 2;
  repeated UnlockMethod methods = 3;
}
message CreateEncryptionKeyRequest {
  string public_key = 1;
  UnlockMethod first = 2;
}
message CreateEncryptionKeyResponse {
  string public_key = 1;
  repeated UnlockMethod methods = 2;
}
message AddUnlockMethodRequest {
  UnlockMethod method = 1;
}
message AddUnlockMethodResponse {
  repeated UnlockMethod methods = 1;
}
message RemoveUnlockMethodRequest {
  string id = 1;
}
message RemoveUnlockMethodResponse {
  repeated UnlockMethod methods = 1;
}

message EnrolmentLinkRequest {
  // The person the link adds a passkey to.
  string principal_id = 1;
  // Refuse a person who already holds a passkey (a paid sign-up's first one).
  bool first_only = 2;
  // When it stops being redeemable. 0: seven days from now. At most thirty
  // days ahead.
  int64 expires_at_unix_ms = 3;
}
message EnrolmentLinkResponse {
  // Shown once; the core keeps only its hash.
  string secret = 1;
  // The secret's hash: the id it is listed and withdrawn by.
  string invite_id = 2;
  int64 expires_at_unix_ms = 3;
}

message MachineChallengeRequest {
  // The browser the relay asks for, as the relay saw it (`198.51.100.7:443`):
  // at most 16 open challenges per client (an IPv6 client by its /64).
  string client = 1;
}
message MachineChallengeResponse {
  // 32 random bytes; clientDataJSON carries them base64url-encoded.
  bytes challenge = 1;
  int64 expires_at_unix_ms = 2;
}