# External signing keys

Attach and resolve an existing OpenPGP signing key.


**Auth:** Own-device signed read, operation `list-external-keys`.
## Request and response

Angle-bracket values describe placeholders, including nested objects and numeric types; these are wire shapes, not captured service data. Replace them before sending.

```http
POST /v1/external-keys/list HTTP/1.1
Host: trust-staging.rebellion.systems
Content-Type: application/json

{
  "graphNodeId": "<own-node-id>",
  "authorization": {
    "graphKeyId": "<device-id>",
    "nonce": "<fresh-random-nonce>",
    "signedAt": "<current-ISO-timestamp>",
    "signature": "<base64-DER-signature>"
  }
}
```

Response shape:

```json role="response"
{
  "externalKeys": [
    {
      "externalKeyId": "<40-hex-fingerprint>",
      "kind": "openpgp",
      "graphNodeId": "<own-node-id>",
      "primaryFingerprint": "<40-hex-primary-fingerprint>",
      "attachedAt": "<timestamp>",
      "revokedAt": "<timestamp-or-null>"
    }
  ]
}
```




An ordinary graph device can attach one selected OpenPGP primary or signing subkey to its node using both a device signature and OpenPGP proof of possession. All routes require a usable, approved graph-purpose device. Bearer credentials and content-signing devices cannot authorize these requests.

## Attach

`POST /v1/external-keys/attach` takes:

```json role="request"
{
  "action": {
    "action": "attach-external-key",
    "graphKeyId": "device-id",
    "externalKeyId": "40-character-lowercase-fingerprint",
    "materialDigest": "64-character-lowercase-sha256",
    "proofDigest": "64-character-lowercase-sha256",
    "nonce": "fresh-random-base64url-nonce",
    "signedAt": "2026-11-04T12:00:00.000Z"
  },
  "signature": "base64-device-action-signature",
  "externalKey": {
    "kind": "openpgp",
    "publicKey": "ASCII-armored public key block"
  },
  "proof": "ASCII-armored clearsigned proof"
}
```

The proof signs exactly this single ASCII line, without a trailing newline:

```text
trustgraph-external-key-v1 node=<graphNodeId> key=<externalKeyId> digest=<materialDigest> nonce=<nonce>
```

`materialDigest` is SHA-256 of sanitized protocol-framed public key material, not the raw armor. `proofDigest` is SHA-256 of the sanitized canonical framed proof signature packet. Use the integration's reviewed OpenPGP preparation implementation; do not substitute a hash of the submitted strings. Freeze material and proof before signing the device action.

The clearsigned proof contains exactly one type 0x01 signature from the selected eligible key. Its hashed creation time must be within one hour of `signedAt`. The action itself follows the normal five-minute freshness window and durable nonce rules.

Success is 201 `{externalKey}`. Attach, revoke and list use this public record:

```typescript
export type ExternalKeyRecord = {
  externalKeyId: string;
  kind: 'openpgp';
  graphNodeId: string;
  primaryFingerprint: string;
  attachedAt: string;
  revokedAt: string | null;
};
```

## Revoke or list

`POST /v1/external-keys/:externalKeyId/revoke` accepts `{action, signature}`. Sign `revoke-external-key` with `graphKeyId`, `externalKeyId`, `nonce` and `signedAt`; omit the attach digests. The signed fingerprint must exactly match the URL or the request returns 403. Success is 200 `{externalKey}`.

`POST /v1/external-keys/list` takes `{graphNodeId, authorization}`, read operation `list-external-keys`. It is own-node only and returns `{externalKeys}`, including revoked records. The per-node limit is 32 total records, counting revoked keys.


**Never**
Fingerprint revocation is terminal. A reserved fingerprint cannot be attached again to the same or another persona. Attach only the exact signing key the user intends to reserve.

## Resolve a signer

`POST /v1/external-keys/resolve-signer` takes either `{graphNodeId, signingFingerprint, authorization}` or `{graphNodeId, signingKeyId, authorization}`. Sign operation `resolve-external-signer` over the exact query. The node ID is your own; supply exactly one locator, never null or both.

Fingerprints are 40 lowercase hex characters; key IDs are their last 16. Key-ID resolution requires a unique reserved fingerprint before lifecycle filtering. Missing, ambiguous, revoked or inactive membership returns the same 404 `EXTERNAL_KEY_NOT_FOUND`.

Success returns `{certificate, status}`. The certificate includes `v: "trust-graph-external-key-certificate-v1"`, issuer, certificate ID, attestation key ID, proven fingerprint, `kind`, parent fingerprint, base64 `signingKeyPacket`, node ID, attachment time, issuance, expiry and signature. The packet is the public-key packet body, not an armored block. Lifetime is 24 hours.

Status uses `v: "trust-graph-external-key-status-v1"` and includes the complete certificate digest, authorization nonce, evaluation time, `valid` / `revoked` / `unavailable`, nullable revocation time and issuer signature. There is no separate external-key status endpoint.

Verify both issuer signatures and pins; recompute the v4 fingerprint from the packet and match the requested locator. Require attachment no later than issuance, issuance no more than 60 seconds ahead, a positive certificate lifetime at most 24 hours, unexpired evidence, and status evaluation within 60 seconds of your clock. Verify the document signature locally. If a path check takes more than 30 seconds after status evaluation, resolve again and require unchanged node and fingerprint before presenting the result.

Before displaying a successful result, recheck expiry. Bound its lifetime by the earliest of certificate expiry, document-signature expiry (when present), status evaluation + 60 seconds, path evaluation + 60 seconds (when used), and check time + 60 seconds. A revoked status must carry a revocation time no later than evaluation; other statuses use null.

## Supported material and errors

Supported v4 keys are RSA 2048–8192, ECDSA P-256/P-384 and legacy-OID Ed25519, with SHA-256/384/512 signatures. Public-key armor is capped at 64 KiB; proof armor at 8 KiB. Signing subkeys require verified binding and back-signature. Other key formats can be refused rather than imported partially.

| Code | HTTP |
| --- | --- |
| `EXTERNAL_KEY_REJECTED`, `EXTERNAL_KEY_UNSUPPORTED`, `EXTERNAL_KEY_MALFORMED` | 400 |
| `EXTERNAL_KEY_PROOF_INVALID`, `EXTERNAL_KEY_ACTION_UNAUTHORIZED` | 401 |
| `EXTERNAL_KEY_UNAVAILABLE` | 409 |
| `EXTERNAL_KEY_NOT_FOUND` | 404 |
| `EXTERNAL_KEYS_SERVER_UNAVAILABLE`, `EXTERNAL_KEY_PERSISTENCE_FAILED` | 503 |

The existing schema, freshness, lifecycle and rate-limit errors also apply. Capability availability depends on the server deployment; unavailable storage returns 503.


**Does not establish**
Attachment establishes current control of one selected key, not control of its siblings or its parent. It makes no claim about historical signing time. The full submitted key block reaches the service for processing; public records omit key material and proof text. Membership alone does not establish a trusted path, human authorship or document safety.
