Files
buzz/docs/nips/NIP-GS.md
T
cls 9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
feat: import Chinese-localized Buzz source snapshot
Signed-off-by: cls_宁波本机 <908705107@qq.com>
2026-08-13 18:34:25 +08:00

38 KiB
Raw Blame History

NIP-GS

Git Object Signing with Nostr Keys

draft optional

Abstract

This NIP defines a signature format and verification protocol for signing git commits and tags with Nostr secp256k1 keys, using git's pluggable signing program interface (gpg.x509.program).

Motivation

Git supports cryptographic commit and tag signing via GPG, SSH, or x509 programs. Nostr users already have secp256k1 keypairs (BIP-340 Schnorr). This NIP allows those same keys to sign git objects — establishing a cryptographic link between a Nostr identity and a git history without requiring GPG keys, SSH keys, or certificate authorities.

The primary use case is autonomous agents that commit code on behalf of their owners. The agent's Nostr keypair — already used for relay authentication (NIP-98), channel membership, and owner attestation (NIP-OA) — now also signs its commits. One identity, one key, across all surfaces.

Human developers with Nostr identities benefit equally: their commits carry the same cryptographic identity as their relay messages, reviews, and approvals.

Non-Goals

This NIP does not define a trust model beyond signature verification. Web-of-trust, allowed-signer lists, and relay-side commit verification are out of scope.

This NIP does not define a new git transport or hosting protocol. It operates entirely within git's existing signing program interface.

This NIP does not require relay changes. Relays are uninvolved — signing and verification happen locally between git and the signing program.

This NIP does not define key management, rotation, or revocation. Consequences of key compromise are discussed in Security Considerations.

How Git Invokes Signing Programs

Git's signing interface is the same for openpgp and x509 formats (both use sign_buffer_gpg and verify_gpg_signed_buffer in gpg-interface.c). The configured program is invoked as:

Signing: <program> --status-fd=<N> -bsau <signing-key>

  • Payload bytes are piped to stdin.
  • The program writes the detached signature to stdout.
  • The program writes [GNUPG:] status lines to file descriptor N.
  • Git checks that [GNUPG:] SIG_CREATED appears in the status output.

Verification: <program> --status-fd=<N> --verify <signature-file> -

  • Payload bytes are piped to stdin.
  • The signature file path is passed as an argument.
  • The program writes [GNUPG:] status lines to file descriptor N.
  • Git parses GOODSIG, BADSIG, VALIDSIG, ERRSIG, and TRUST_* from the status output.

This NIP uses gpg.format=x509 because:

  1. The x509 format uses -----BEGIN SIGNED MESSAGE----- markers, which do not collide with PGP (-----BEGIN PGP SIGNATURE-----) or SSH (-----BEGIN SSH SIGNATURE-----) markers. Platforms that attempt to verify PGP signatures (e.g., GitHub) will not misparse them.
  2. The x509 verify path passes no extra arguments (x509_verify_args is empty in git's source), while openpgp adds --keyid-format=long.
  3. sigstore/gitsign (1,000+ stars) established this pattern successfully.

Specification

Signature Format

The signing program MUST produce a detached signature wrapped in armor:

-----BEGIN SIGNED MESSAGE-----
<base64>
-----END SIGNED MESSAGE-----

The armor MUST consist of exactly three lines separated by \n (LF, 0x0A), followed by a final \n. That is, the output is exactly:

-----BEGIN SIGNED MESSAGE-----\n<base64>\n-----END SIGNED MESSAGE-----\n

The first line MUST be exactly -----BEGIN SIGNED MESSAGE-----. The last line MUST be exactly -----END SIGNED MESSAGE-----. The middle line MUST be a single base64-encoded string using the standard alphabet (RFC 4648 §4) with = padding. Line wrapping MUST NOT be used. The encoded base64 line MUST NOT exceed 4096 bytes (sufficient for the 2048-byte decoded JSON limit with base64 overhead). Trailing whitespace on any line MUST NOT be present. CRLF line endings MUST NOT be used.

Verifiers MUST accept a trailing \n after the end marker (git may append one). Verifiers MUST reject signatures with:

  • Missing or malformed armor headers.
  • Multiple armor blocks.
  • Line-wrapped base64.
  • Base64 line exceeding 4096 bytes.
  • Any bytes after the end marker other than a single \n.

The base64 content decodes to a JSON object:

{
  "v": 1,
  "pk": "<pubkey-hex>",
  "sig": "<signature-hex>",
  "t": <created-at>,
  "oa": ["<owner-pubkey-hex>", "<conditions>", "<sig-hex>"]
}
Field Type Required Constraints Description
v integer MUST MUST be 1 Schema version.
pk string MUST Exactly 64 lowercase hex characters. MUST be a valid BIP-340 x-only public key (i.e., the x-coordinate of a point on the secp256k1 curve). Signer's public key.
sig string MUST Exactly 128 lowercase hex characters. BIP-340 Schnorr signature over the git object.
t integer MUST MUST be in the range 0 to 4294967295. MUST NOT be negative, a float, or a string. Claimed unix timestamp (seconds) of the signing event. See Security Considerations for implications of signer-controlled timestamps.
oa array OPTIONAL If present, MUST be a JSON array of exactly 3 strings. See Owner Attestation. NIP-OA owner attestation proving the signer was authorized by an owner key.

JSON parsing rules:

  • The base64-decoded bytes MUST be valid UTF-8. Verifiers MUST reject if the decoded bytes contain invalid UTF-8 sequences.
  • The content MUST be a single JSON object (not an array, string, or primitive).
  • The JSON MUST use compact serialization: no whitespace outside of string values. Verifiers MUST reject JSON containing spaces, tabs, or newlines outside of string values. This prevents envelope malleability — since the signature envelope is embedded in the git commit object, any byte change alters the commit hash.
  • Duplicate keys: verifiers MUST reject the signature. Implementations SHOULD use a JSON parser configured to fault on duplicate keys, or verify key uniqueness before parsing.
  • For v=1, the only permitted keys are v, pk, sig, t, and oa. Any other key MUST cause rejection. Future versions (v=2, etc.) define their own field sets. This prevents unsigned extension fields from being injected into the envelope.
  • The total decoded JSON MUST NOT exceed 2048 bytes (the oa field adds ~200 bytes to the base ~250-byte envelope).
  • Implementations MUST reject non-canonical hex (uppercase, odd-length, whitespace).

Signing Hash

All envelope metadata (t, oa) is included in the hash preimage so that it is cryptographically bound to the signature. Tampering with any field invalidates the signature.

Given a git object payload (the bytes git pipes to stdin), a signing timestamp t, and an optional owner attestation:

hash = SHA-256( "nostr:git:v1:" || decimal(t) || ":" || oa_binding || payload_bytes )

Where:

  • "nostr:git:v1:" is the domain separator: exactly 13 bytes of UTF-8 (6e6f7374723a6769743a76313a).
  • decimal(t) is the ASCII decimal encoding of t with no leading zeroes (except 0 itself). Example: 1700000000.
  • ":" is a single colon byte (3a), separating the timestamp from the next field.
  • oa_binding is:
    • If oa is present: oa[0] || ":" || oa[1] || ":" || oa[2] || ":" (the three oa array elements concatenated with colon separators, followed by a trailing colon). All elements are their exact string values (hex pubkey, conditions string which may be empty, hex signature).
    • If oa is absent: empty (zero bytes). The colon after decimal(t) is immediately followed by payload_bytes.
  • payload_bytes is the raw bytes git pipes to stdin.

Important: Because the oa data is included in the signing hash, stripping or modifying the oa field invalidates the NIP-GS sig. This is intentional — the signature envelope is immutable once signed.

The domain separator prevents cross-protocol signature reuse:

  • NIP-01 event signatures sign SHA-256(serialized_event) — different preimage.
  • NIP-98 HTTP auth signatures sign a kind:27235 event — different preimage.
  • NIP-OA attestations sign SHA-256("nostr:agent-auth:" || ...) — different domain separator.

Signing Procedure

  1. Record the current unix timestamp as t.
  2. Read the git object payload from stdin. If the payload exceeds 100 MB, exit with code 1 and a diagnostic on stderr. MUST NOT write to stdout.
  3. Compute the signing hash per the Signing Hash section: hash = SHA-256("nostr:git:v1:" || decimal(t) || ":" || oa_binding || payload). If including oa, the oa_binding is oa[0] || ":" || oa[1] || ":" || oa[2] || ":". If not including oa, the oa_binding is empty (zero bytes).
  4. Produce a BIP-340 Schnorr signature over hash using the signer's secret key. Implementations MUST use a cryptographically secure nonce per BIP-340 §4. Implementations SHOULD use auxiliary randomness (BIP-340 §4 default nonce generation) to mitigate side-channel attacks. Implementations MAY use deterministic nonce generation (RFC 6979 adapted to BIP-340) for reproducible test vectors.
  5. Construct the JSON object with compact serialization (no whitespace). Field order MUST be v, pk, sig, t, then oa if present. Example without oa: {"v":1,"pk":"<hex>","sig":"<hex>","t":<integer>} Example with oa: {"v":1,"pk":"<hex>","sig":"<hex>","t":<integer>,"oa":["<owner>","","<sig>"]}
  6. Base64-encode the JSON bytes (standard alphabet, with padding).
  7. Write to stdout:
    -----BEGIN SIGNED MESSAGE-----\n<base64>\n-----END SIGNED MESSAGE-----\n
    
  8. Write to the status file descriptor:
    [GNUPG:] BEGIN_SIGNING\n[GNUPG:] SIG_CREATED D 8 1 00 <t> <pk>\n
    
    Where <t> is the decimal timestamp and <pk> is the 64-character hex public key. The tokens D 8 1 00 are fixed compatibility placeholders that satisfy git's SIG_CREATED parser. They do not carry semantic meaning for nostr signatures. (D = detached, 8 = SHA-256 in GPG's algorithm numbering, 1 and 00 are reserved fields git does not interpret.)

Verification Procedure

  1. Read the signature file. Validate armor format per the rules above.

  2. Base64-decode the middle line. Verify the decoded bytes are valid UTF-8. Parse as JSON. To prevent envelope malleability, after parsing and validating all fields, the verifier MUST reconstruct the canonical JSON string using the exact parsed values in the required field order (v, pk, sig, t, then oa if present) with compact serialization (no whitespace). The reconstructed string MUST exactly match the base64-decoded string byte-for-byte. Any deviation in field order, number formatting (e.g., 1.7e9 instead of 1700000000), or unexpected whitespace MUST result in ERRSIG, exit 1. This ensures the signature envelope is non-malleable — there is exactly one valid byte sequence for any given set of field values.

  3. Validate all fields per the constraints table. If any field is invalid or missing, write ERRSIG (see below) and exit with code 1.

  4. If v is not 1, write ERRSIG and exit with code 1.

  5. Validate that pk is a valid BIP-340 x-only public key (not just hex — the value must be the x-coordinate of a point on secp256k1, i.e., lift_x(pk) must succeed per BIP-340 §5.3.2).

  6. Read the git object payload from stdin. If the payload exceeds 100 MB, write ERRSIG to the status fd and exit with code 1.

  7. Compute the signing hash per the Signing Hash section. If the oa field is present and structurally valid (array of 3 strings), include the oa_binding: hash = SHA-256("nostr:git:v1:" || decimal(t) || ":" || oa[0] || ":" || oa[1] || ":" || oa[2] || ":" || payload). If oa is absent: hash = SHA-256("nostr:git:v1:" || decimal(t) || ":" || payload).

  8. Verify the BIP-340 Schnorr signature sig over hash against public key pk.

  9. If verification fails, write to the status fd:

    [GNUPG:] NEWSIG\n[GNUPG:] BADSIG <pk> <pk>\n
    

    Exit with code 1.

  10. If verification succeeds, determine trust level:

    • Read user.signingkey from git config (git config --get user.signingkey).
    • If the value is an npub1... string, decode it to 64-character hex per NIP-19 before comparison.
    • If user.signingkey is set AND pk equals the normalized hex value (case-insensitive comparison) → trust level is FULLY.
    • Otherwise → trust level is UNDEFINED.

    Note: TRUST_FULLY means only "this is the locally configured signing key" — it is NOT a global trust assertion. For verifying other people's commits, applications SHOULD implement an allowed-signers mechanism (outside the scope of this NIP) rather than relying on TRUST_FULLY.

  11. Write to the status fd:

    [GNUPG:] NEWSIG
    [GNUPG:] GOODSIG <pk> <pk>
    [GNUPG:] VALIDSIG <pk> <date> <t> 0 - - - - - <pk>
    [GNUPG:] TRUST_FULLY 0 shell
    

    Or TRUST_UNDEFINED instead of TRUST_FULLY if the key is unknown. Exit with code 0.

Status Line Formats

Each status line is [GNUPG:] (9 bytes, including trailing space) followed by the status keyword and space-separated fields, terminated by \n.

SIG_CREATED (signing success):

[GNUPG:] SIG_CREATED D 8 1 00 <t_decimal> <pk_hex_64>

GOODSIG (verification success):

[GNUPG:] GOODSIG <pk_hex_64> <pk_hex_64>

First field: key ID. Second field: user ID. Both are the hex pubkey.

BADSIG (signature cryptographically invalid):

[GNUPG:] BADSIG <pk_hex_64> <pk_hex_64>

ERRSIG (signature could not be processed — malformed, unknown version, etc.):

[GNUPG:] ERRSIG <key_id> 0 0 00 0 9

Where <key_id> is the pk field if parseable, or 16 zero bytes (0000000000000000) if pk could not be extracted. The trailing fields are fixed placeholders (algo, hash algo, class, timestamp, rc=9 meaning "no public key" / general error).

VALIDSIG (fingerprint and timestamp — emitted after GOODSIG):

[GNUPG:] VALIDSIG <fpr> <date> <t_decimal> 0 - - - - - <primary_fpr>

Where:

  • <fpr> is the 64-character hex pubkey (fingerprint).
  • <date> is the signing date in YYYY-MM-DD format, derived from t interpreted as UTC. Implementations MUST use UTC for this conversion.
  • <t_decimal> is the decimal unix timestamp from the signature.
  • 0 is the expiration timestamp (no expiration).
  • The five - tokens are reserved fields. Git's parser skips 9 space-separated tokens after the fingerprint to find the primary key fingerprint.
  • <primary_fpr> is the primary key fingerprint (same as <fpr> — Nostr keys have no subkey hierarchy).

TRUST_* (trust level — emitted after VALIDSIG):

[GNUPG:] TRUST_FULLY 0 shell
[GNUPG:] TRUST_UNDEFINED 0 shell

Key Loading

The signing program MUST load the signer's secret key from one of the following sources, checked in order:

  1. NOSTR_PRIVATE_KEY environment variable.
  2. BUZZ_PRIVATE_KEY environment variable.
  3. A keyfile at the path specified by nostr.keyfile git config key.

Each source accepts the key in either nsec1... (NIP-19 bech32) or 64-character hex format. Leading and trailing whitespace MUST be trimmed before parsing. Any other format MUST be rejected.

The program MUST zeroize secret key material from memory after use.

On Unix systems, if loading from a keyfile, the program MUST verify file permissions are no broader than 0600 (owner read/write only). If permissions are broader, the program MUST exit with an error and a diagnostic on stderr.

For verification, no secret key is needed — only the public key embedded in the signature.

Signing Key Argument

Git passes the user.signingkey value as the -u <key> argument. The signing program SHOULD verify that the loaded private key corresponds to the public key specified in <key> (if <key> is a hex pubkey or npub). If they do not match, the program MUST exit with an error. This prevents accidentally signing with the wrong key.

If <key> is empty or not a recognizable key format, the program MAY ignore it and sign with whatever key is loaded.

Owner Attestation (Optional)

Agent processes — AI agents, CI bots, automation — often act on behalf of a human owner. The optional oa field embeds a NIP-OA owner attestation directly in the signature envelope, allowing anyone to verify offline that the signing key was authorized by a specific owner key.

Signing programs that have access to a NIP-OA auth tag SHOULD include it. Human signers who are their own authority SHOULD omit it.

Format

The oa field, when present, MUST be a JSON array of exactly 3 strings:

"oa": ["<owner-pubkey-hex>", "<conditions>", "<owner-sig-hex>"]
Index Type Constraints Description
0 string 64 lowercase hex characters. MUST be a valid BIP-340 x-only public key. MUST NOT equal pk. Owner's public key.
1 string UTF-8 string. MAY be empty. If non-empty, clauses separated by & per NIP-OA. NIP-OA conditions string.
2 string 128 lowercase hex characters. BIP-340 Schnorr signature by the owner key.

This mirrors the NIP-OA auth tag format (elements 13; the "auth" label is omitted since the field name oa already identifies it).

Verification

To verify the owner attestation:

  1. Confirm oa is an array of exactly 3 strings. If not, reject the entire signature (ERRSIG). Structurally malformed envelopes are always rejected to prevent malleability — the oa field is part of the signed hash preimage, so its structure must be valid.

  2. Validate the owner pubkey (index 0): 64 lowercase hex, valid BIP-340 key, not equal to pk. If invalid, reject (ERRSIG).

  3. Compute the NIP-OA signing preimage:

    preimage = "nostr:agent-auth:" || pk || ":" || conditions
    

    Where pk is the signer's pubkey from the pk field (the agent), and conditions is the string at index 1 (may be empty).

  4. Compute hash = SHA-256(preimage).

  5. Verify the BIP-340 Schnorr signature at index 2 over hash against the owner pubkey at index 0.

  6. If verification succeeds, the attestation is valid: the owner key authorized the agent key. If conditions are non-empty, evaluate them per NIP-OA rules (noting that kind= and created_at conditions reference Nostr event fields and are not meaningful for git commits — see below).

  7. If verification fails, the NIP-GS commit signature (sig) may still be valid, but the owner attestation is invalid. Verifiers SHOULD report the commit as signed but the owner authorization as failed/unverified.

Conditions in Git Context

NIP-OA conditions (kind=<n>, created_at<t>, created_at>t) reference Nostr event fields that do not exist in git commits. For git commit signing:

  • An empty conditions string (unconditional authorization) is RECOMMENDED. It means "this owner authorized this agent key" with no constraints.

  • If conditions are present, verifiers SHOULD evaluate only the conditions they can meaningfully check. created_at<t and created_at>t MAY be evaluated against the signature timestamp t from the NIP-GS envelope as a reasonable approximation, but this is not required.

  • kind=<n> conditions have no git equivalent and SHOULD be ignored by git signature verifiers.

Signing programs SHOULD use auth tags with empty conditions for git signing.

Trust Display

When displaying verification results for a commit with a valid oa field:

  • The commit is "signed by <pk>" (the agent).
  • The commit is "authorized by <owner-pubkey>" (the owner).
  • These MUST be displayed as distinct facts. The owner did not sign the commit; the owner authorized the key that signed the commit.

Immutability

The oa field is included in the NIP-GS signing hash (see Signing Hash). If an attacker strips or modifies the oa field, the NIP-GS sig becomes invalid. This means the signature envelope is immutable once signed — you cannot downgrade an owner-authorized commit to an agent-only commit without invalidating the entire signature.

This also means the signing program must know at signing time whether to include oa. The oa field cannot be added after the fact.

Loading the Auth Tag

The signing program SHOULD load the NIP-OA auth tag from one of the following sources, checked in order:

  1. BUZZ_AUTH_TAG environment variable — a JSON-encoded array of 4 strings (["auth", "<owner>", "<conditions>", "<sig>"]). The program extracts elements 13 for the oa field.
  2. nostr.authtag git config key — same JSON format.

If no auth tag is available, the oa field is omitted. This is not an error.

Git Configuration

To enable nostr signing for commits and tags:

[gpg]
    format = x509
    x509.program = /path/to/git-sign-nostr
[commit]
    gpgsign = true
[tag]
    gpgsign = true
[user]
    signingkey = <hex-pubkey>

These MAY be set via GIT_CONFIG_COUNT / GIT_CONFIG_KEY_* / GIT_CONFIG_VALUE_* environment variables for ephemeral, per-process configuration (e.g., when spawning agent processes).

Implementations SHOULD use process-scoped configuration to avoid interfering with the user's existing GPG or SSH signing setup.

Warning: GIT_CONFIG_COUNT replaces any parent-process GIT_CONFIG_* variables entirely. Spawning processes that set GIT_CONFIG_COUNT MUST account for any existing GIT_CONFIG_* variables they need to preserve.

CLI Interface

Implementations MUST accept the following argument patterns:

Pattern Mode
--status-fd=<N> or --status-fd <N> File descriptor N for [GNUPG:] status output
-bsau <key> Signing mode. <key> is the signing key identifier from user.signingkey.
--verify <file> - Verification mode. <file> is the path to the detached signature file.

Implementations SHOULD silently ignore unrecognized arguments for forward compatibility with future git versions (e.g., --keyid-format=long from the openpgp path, though x509 does not currently pass it).

If --status-fd is not provided, the program SHOULD write status lines to stderr (fd 2) as a fallback. If the status fd is not writable, the program SHOULD continue signing/verifying but skip status output. Git will treat the absence of SIG_CREATED as a signing failure.

Test Vectors

Test Key

Secret key (hex): 0000000000000000000000000000000000000000000000000000000000000003
Public key (hex): f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9

Test Payload

A minimal git commit object (170 bytes, no trailing LF):

tree 4b825dc642cb6eb9a060e54bf899d69f7cb46101
author Test User <test@example.com> 1700000000 +0000
committer Test User <test@example.com> 1700000000 +0000

Initial commit

Payload hex (170 bytes):

7472656520346238323564633634326362366562396130363065353462663839
39643639663763623436313031 0a 617574686f7220546573742055736572
203c74657374406578616d706c652e636f6d3e2031373030303030303030202b
30303030 0a 636f6d6d6974746572205465737420557365722 03c7465737440
6578616d706c652e636f6d3e2031373030303030303030202b30303030 0a0a
496e697469616c20636f6d6d6974

Note: the 0a bytes are LF line endings within the commit object. There is no trailing 0a after Initial commit. Git pipes exactly these bytes to the signing program's stdin.

Signing Hash

Domain separator: "nostr:git:v1:" (13 bytes, hex: 6e6f7374723a6769743a76313a)
Timestamp:        1700000000
Timestamp ASCII:  "1700000000" (10 bytes)
Separator colon:  ":" (1 byte, hex: 3a)

Preimage: "nostr:git:v1:" || "1700000000" || ":" || <payload_bytes>
Preimage length: 13 + 10 + 1 + 170 = 194 bytes

SHA-256(preimage): a11a32173aa35125aaefaad8854f2eda5a144268a4a355905c841f79ff44aa18

Verification: any conforming implementation MUST produce this hash for the given payload and timestamp.

Deterministic Signature Vector

The following signature was produced using sign_schnorr_no_aux_rand (BIP-340 signing with auxiliary randomness set to 32 zero bytes). This is deterministic: any implementation using the same nonce derivation MUST produce this exact signature.

Signature (hex, 128 chars):
c35062148d95b820068c18ab9cf69a8dd2322c606890366d084df7617570b96b
7a1aca0a8fcabb2eb4032ebbdf5b43e6bf8633e0d85bcecce28a9e08705b875f

JSON (compact, no whitespace):

{"v":1,"pk":"f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9","sig":"c35062148d95b820068c18ab9cf69a8dd2322c606890366d084df7617570b96b7a1aca0a8fcabb2eb4032ebbdf5b43e6bf8633e0d85bcecce28a9e08705b875f","t":1700000000}

Base64:

eyJ2IjoxLCJwayI6ImY5MzA4YTAxOTI1OGMzMTA0OTM0NGY4NWY4OWQ1MjI5YjUzMWM4NDU4MzZmOTliMDg2MDFmMTEzYmNlMDM2ZjkiLCJzaWciOiJjMzUwNjIxNDhkOTViODIwMDY4YzE4YWI5Y2Y2OWE4ZGQyMzIyYzYwNjg5MDM2NmQwODRkZjc2MTc1NzBiOTZiN2ExYWNhMGE4ZmNhYmIyZWI0MDMyZWJiZGY1YjQzZTZiZjg2MzNlMGQ4NWJjZWNjZTI4YTllMDg3MDViODc1ZiIsInQiOjE3MDAwMDAwMDB9

Full armored output:

-----BEGIN SIGNED MESSAGE-----
eyJ2IjoxLCJwayI6ImY5MzA4YTAxOTI1OGMzMTA0OTM0NGY4NWY4OWQ1MjI5YjUzMWM4NDU4MzZmOTliMDg2MDFmMTEzYmNlMDM2ZjkiLCJzaWciOiJjMzUwNjIxNDhkOTViODIwMDY4YzE4YWI5Y2Y2OWE4ZGQyMzIyYzYwNjg5MDM2NmQwODRkZjc2MTc1NzBiOTZiN2ExYWNhMGE4ZmNhYmIyZWI0MDMyZWJiZGY1YjQzZTZiZjg2MzNlMGQ4NWJjZWNjZTI4YTllMDg3MDViODc1ZiIsInQiOjE3MDAwMDAwMDB9
-----END SIGNED MESSAGE-----

Verification: any conforming implementation MUST accept this signature for the test key and payload above. Implementations using random auxiliary randomness will produce different (but equally valid) signatures.

Expected signing status output:

[GNUPG:] BEGIN_SIGNING
[GNUPG:] SIG_CREATED D 8 1 00 1700000000 f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9

Verification Status Output

For a valid signature where pk matches user.signingkey:

[GNUPG:] NEWSIG
[GNUPG:] GOODSIG f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9 f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9
[GNUPG:] VALIDSIG f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9 2023-11-14 1700000000 0 - - - - - f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9
[GNUPG:] TRUST_FULLY 0 shell

For a valid signature where pk does NOT match user.signingkey:

[GNUPG:] NEWSIG
[GNUPG:] GOODSIG f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9 f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9
[GNUPG:] VALIDSIG f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9 2023-11-14 1700000000 0 - - - - - f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9
[GNUPG:] TRUST_UNDEFINED 0 shell

For an invalid signature:

[GNUPG:] NEWSIG
[GNUPG:] BADSIG f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9 f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9

Owner Attestation Test Vector

Using the same agent key (secret=0x03) and an owner key (secret=0x01):

Owner secret:  0000000000000000000000000000000000000000000000000000000000000001
Owner pubkey:  79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798
Agent pubkey:  f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9
Conditions:    "" (empty — unconditional authorization)

NIP-OA preimage:

"nostr:agent-auth:f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9:"

SHA-256 of preimage:

05113b24677b87bedf6498a3addad720003e6af36820e859a26814f149f5a837

Owner signature (deterministic, no aux randomness):

54b97dfd2b7d61c1bc1b5facab9d12a991fe0ac3dcb9044b3176f63bebb6f673
40eb0ad866f2d5568b78b58ba234ee9f490f8c41e64a949c200315801520ed25

NIP-GS signing hash (with oa binding):

Preimage: "nostr:git:v1:" || "1700000000" || ":" || oa[0] || ":" || oa[1] || ":" || oa[2] || ":" || payload
SHA-256:  b61f1658836a4f63a2d2f5d621014a064435dde0765dd9c1dc79c9530fe879f0

NIP-GS signature (deterministic, no aux randomness):

15592857980b8656ff50303d86acaffcbda397b9c0bb40aebd2fb87a723e466f
db1a74404d39f9eb7ac220b4f2e061f27523f1af24cbdf991cf42ff9b47034c0

Full JSON with oa (compact):

{"v":1,"pk":"f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9","sig":"15592857980b8656ff50303d86acaffcbda397b9c0bb40aebd2fb87a723e466fdb1a74404d39f9eb7ac220b4f2e061f27523f1af24cbdf991cf42ff9b47034c0","t":1700000000,"oa":["79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","","54b97dfd2b7d61c1bc1b5facab9d12a991fe0ac3dcb9044b3176f63bebb6f67340eb0ad866f2d5568b78b58ba234ee9f490f8c41e64a949c200315801520ed25"]}

Verification chain:

  1. Verify sig over SHA-256("nostr:git:v1:1700000000:" || oa_binding || payload) against pk → commit signed by agent, oa is bound
  2. Verify oa[2] over SHA-256("nostr:agent-auth:" || pk || ":" || oa[1]) against oa[0] → agent authorized by owner

For a malformed signature (unparseable JSON, unknown version, etc.):

[GNUPG:] ERRSIG 0000000000000000 0 0 00 0 9

Invalid Cases

Implementations MUST handle the following:

  • Signature file with missing or malformed armor headers — ERRSIG, exit 1.
  • Base64 content that does not decode — ERRSIG, exit 1.
  • Decoded content that is not a JSON object — ERRSIG, exit 1.
  • JSON with duplicate keys — ERRSIG, exit 1.
  • JSON with v absent or not equal to integer 1ERRSIG, exit 1.
  • JSON with unknown keys (for v=1, only v, pk, sig, t, oa are permitted) — ERRSIG, exit 1.
  • oa present but not an array of exactly 3 strings — ERRSIG, exit 1. (Structurally malformed envelopes are always rejected to prevent malleability.)
  • oa[0] (owner pubkey) equals pk (self-attestation) — ERRSIG, exit 1.
  • oa[2] (owner signature) fails NIP-OA BIP-340 verification — the NIP-GS commit signature (sig) is still valid, but the owner attestation is invalid. Verifiers SHOULD report GOODSIG (the commit is signed) but display the owner authorization as failed/unverified.
  • pk not exactly 64 lowercase hex characters — ERRSIG, exit 1.
  • pk that is not a valid BIP-340 x-only public key (lift_x fails) — ERRSIG, exit 1.
  • sig not exactly 128 lowercase hex characters — ERRSIG, exit 1.
  • t not an integer, or outside range 04294967295 — ERRSIG, exit 1.
  • Decoded JSON exceeding 2048 bytes — ERRSIG, exit 1.
  • Payload exceeding 100 MB — the program MUST reject with an error on stderr.
  • Secret key not available during signing — exit 1 with a diagnostic on stderr. MUST NOT write to stdout (git interprets any stdout as signature data).
  • Signing key argument (-u <key>) does not match loaded key — exit 1 with diagnostic on stderr.

Security Considerations

Domain Separation

The nostr:git:v1: prefix in the hash preimage ensures that a signature over a git object cannot be replayed in another context. The timestamp is included in the preimage so that t is cryptographically bound — tampering with t invalidates the signature.

Signer-Controlled Timestamp

The timestamp t is set by the signer and included in the signed hash, so it cannot be altered by a third party. However, the signer can choose any value — including past or future timestamps. The t field represents a claimed signing time, not a verified one. Applications that require trusted timestamps SHOULD cross-reference t with the commit's author and committer timestamps, or with external timestamping services.

Replay Across Repositories

A signed git object (commit or tag) is valid wherever it appears. If the same commit object is cherry-picked or grafted into another repository, the signature remains valid. This is intentional and consistent with how GPG-signed commits behave in git. The signature attests "this key signed this content at this time" — not "this content belongs in this repository."

Key Compromise

This NIP provides no built-in key revocation or expiration mechanism. If a signer's secret key is compromised:

  • All past signatures remain valid. There is no way to retroactively invalidate them within this protocol.
  • The attacker can sign arbitrary commits as the compromised identity.
  • Applications SHOULD implement out-of-band revocation (e.g., publishing a revocation event on Nostr relays, updating allowed-signer lists) and SHOULD NOT rely solely on commit signatures for authorization decisions.

Key Exposure via Environment Variables

The signing program reads secret keys from environment variables (NOSTR_PRIVATE_KEY, BUZZ_PRIVATE_KEY). Environment variables are visible to:

  • The process itself and its children.
  • On Linux, any process that can read /proc/<pid>/environ (same UID or root).
  • Shell history if the variable was set inline (e.g., NOSTR_PRIVATE_KEY=... git commit).
  • CI/CD logs if the variable is echoed or logged.

This exposure model is acceptable for agent processes running in a controlled environment (e.g., spawned by a desktop app with process-scoped env vars). For human users with higher security requirements, implementations MAY support NIP-46 (Nostr Remote Signing) in a future version.

Implementations MUST NOT log, print, or include the secret key in error messages.

Signing Program Trust

The signing program path is configured via gpg.x509.program. A malicious program at that path could steal the secret key or produce fraudulent signatures. Implementations that inject this configuration (e.g., desktop apps spawning agents) SHOULD use absolute paths resolved from trusted locations (e.g., Tauri sidecar binaries) and SHOULD NOT rely on PATH resolution in untrusted environments.

Repository-local .gitconfig can override gpg.x509.program. Users SHOULD be aware that cloning an untrusted repository could redirect signing to a malicious program if include.path or safe.directory settings allow it.

Nonce Generation

BIP-340 §4 specifies nonce generation using auxiliary randomness. Poor nonce generation (e.g., reusing a nonce across two different messages) can expose the private key. Implementations MUST use a cryptographically secure random number generator for auxiliary randomness, or use a deterministic nonce derivation scheme that is provably secure (e.g., RFC 6979 adapted to BIP-340).

Envelope Immutability

The oa field and all other envelope metadata are included in the NIP-GS signing hash. Any modification to the JSON envelope — adding, removing, or changing fields — invalidates the sig. This prevents:

  • Stripping attacks: removing oa to downgrade from "owner-authorized" to "agent-only."
  • Injection attacks: adding a fraudulent oa to claim false authorization.
  • Whitespace attacks: adding spaces or newlines to the JSON to create a different git commit hash while keeping the signature valid.

Because the signature envelope is embedded in the git commit object (in the gpgsig header), and the git commit hash covers the entire object, any change to the envelope also changes the commit hash. By binding the envelope contents to the NIP-GS signature, we ensure that a valid signature corresponds to exactly one commit hash.

Identity Binding

A verified nostr commit signature proves "this secp256k1 key signed this git object." It does NOT prove:

  • The signer is a specific person (that requires out-of-band identity verification, e.g., NIP-05).
  • The signer is authorized to commit to this repository (that requires application-level access control).
  • The commit content is trustworthy (that requires code review).

Applications that display signature status SHOULD make these distinctions clear to users.

Denial of Service

The 2048-byte JSON limit, 4096-byte base64 limit, and 100 MB payload limit bound resource consumption during verification. The base64 decoding step is bounded by the JSON limit. Implementations SHOULD also bound the time spent on BIP-340 verification (a single verification is fast, but a malicious actor could craft many signed objects).

Relationship to Other NIPs

NIP Relationship
NIP-01 Nostr event signing uses the same secp256k1 keys but different hash preimages (domain separation).
NIP-34 Git repository metadata and patches. This NIP adds commit-level signatures to NIP-34 workflows.
NIP-98 HTTP auth for git transport. NIP-98 authenticates the pusher; this NIP authenticates the committer. They are complementary.
NIP-OA Owner attestation. The optional oa field embeds a NIP-OA credential in the signature envelope, proving the agent was authorized by an owner. With empty conditions, this is pure key-to-key identity binding.
NIP-46 Remote signing. Future implementations MAY delegate signing to a NIP-46 bunker, keeping the secret key on a separate device.

Kind Usage

This NIP does not define any Nostr event kinds. Signatures are embedded in git objects, not published to relays.

Backwards Compatibility

This NIP introduces no changes to existing Nostr event kinds, relay behavior, or git protocols. It uses only git's standard pluggable signing program interface. Repositories signed with this NIP are readable by any git client — unsigned clients simply see unverified signatures. The -----BEGIN SIGNED MESSAGE----- armor markers are recognized by git's x509 signature detection and will not collide with PGP or SSH signatures.

References