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;
}