adding ssh host signing
This commit is contained in:
@@ -10,7 +10,7 @@ Authoritative design: [`../../docs/tpm-enrolment-design.md`](../../docs/tpm-enro
|
||||
This is **v1** (fingerprint-only OOB assurance; inline empty-NV vehicle on the host
|
||||
side). TPM attestation and the host-side dracut branch are the next phases.
|
||||
|
||||
## Two listeners, by design
|
||||
## Three listeners, by design
|
||||
|
||||
```
|
||||
PUBLIC (plain HTTP, behind the serving nginx /enrol/) UNAUTHENTICATED
|
||||
@@ -23,10 +23,28 @@ ADMIN (mTLS, management network / loopback ONLY) admin client cert
|
||||
GET /pending JSON queue
|
||||
POST /approve/{id} OOB-approve -> step ca sign -> store cert
|
||||
POST /deny/{id} reject
|
||||
|
||||
HOST (mTLS, boot LAN) FLEET host client cert
|
||||
POST /ssh/hostcert {publicKey, principals, keyID} -> {cert}
|
||||
GET /healthz
|
||||
```
|
||||
|
||||
The public side is deliberately tiny and stdlib-only — it is the only thing
|
||||
reachable by un-enrolled devices. The admin side never touches the boot LAN.
|
||||
reachable by un-enrolled devices. The admin side never touches the boot LAN. The
|
||||
**host plane** (F-02/P-17) is the automatic, self-authenticating counterpart of
|
||||
enrolment: an already-enrolled host proves itself with its **TPM identity** as the
|
||||
mTLS client cert (verified to the fleet CA) and the broker has step-ca sign an SSH
|
||||
**host** certificate — no human approval, no CA secret on the host. This keeps
|
||||
step-ca behind the single front door for SSH host certs too. The host plane is
|
||||
**disabled unless `BROKER_HOST_ADDR` is set**. Enable the SSH CA on step-ca +
|
||||
issue its certs with `serving/dot1x-bench/step-ca/ssh-ca-bootstrap.sh`; the client
|
||||
is `files/ssh-hostcert` (docs/ssh-host-certificate-design.md).
|
||||
|
||||
> **Client CA differs by plane:** the ADMIN plane uses a *dedicated admin CA*
|
||||
> (`admin-pki.sh`) — deliberately **not** the fleet root. The HOST plane uses the
|
||||
> **fleet CA** (`BROKER_HOST_CLIENT_CA` = root+intermediate), because host clients
|
||||
> present fleet-signed TPM identities. Its *server* cert must also be fleet-signed
|
||||
> so the booted host verifies it against `radius-ca.pem`.
|
||||
|
||||
## How identity is gated
|
||||
|
||||
@@ -84,6 +102,11 @@ authenticate to the admin plane and approve enrolments (privilege escalation).
|
||||
| `HOSTNQN_BASE` | `nqn.2026-06.org.example.fleet:host-` | hostnqn = base + cert-serial |
|
||||
| `BROKER_ADMIN_CERT` / `_KEY` / `_CLIENT_CA` | — | admin mTLS (required unless insecure) |
|
||||
| `BROKER_ADMIN_INSECURE` | `false` | **dev only**: plaintext admin, no client-cert auth |
|
||||
| `BROKER_HOST_ADDR` | — (disabled) | SSH host-cert plane listener; unset ⇒ off |
|
||||
| `BROKER_HOST_CERT` / `_KEY` | — | host-plane server cert/key (**fleet-signed**) |
|
||||
| `BROKER_HOST_CLIENT_CA` | `STEP_CA_ROOT` | verifies host TPM identities (**fleet** root+intermediate) |
|
||||
| `BROKER_HOST_INSECURE` | `false` | **dev only**: plaintext host plane, no client-cert auth |
|
||||
| `STEP_SSH_CERT_NOT_AFTER` | `720h` | issued SSH host-cert validity |
|
||||
|
||||
Swap `wordlist.txt` for the 2048-word BIP39 list (11 bits/word) for more entropy
|
||||
per word; it just needs to be a power-of-two number of lines.
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// host.go — the mTLS HOST plane: enrolled hosts get their per-boot SSH host key
|
||||
// signed by the fleet SSH CA.
|
||||
//
|
||||
// This is the THIRD listener, distinct from the two in main.go:
|
||||
//
|
||||
// HOST (mTLS, boot-LAN reachable) -- fleet-CA host client cert:
|
||||
// POST /ssh/hostcert {publicKey, principals, keyID} -> {cert}
|
||||
//
|
||||
// It is the automatic, self-authenticating counterpart of enrolment: a host
|
||||
// proves it is enrolled by presenting its TPM identity as the mTLS client cert
|
||||
// (verified to the fleet CA), and the broker has step-ca sign an SSH HOST cert —
|
||||
// no human approval, no CA secret on the host. Admins then trust every fleet host
|
||||
// via one `@cert-authority` line instead of per-boot TOFU (F-02 / POA&M P-17).
|
||||
// Keep step-ca itself off the boot LAN; the broker is the single front door.
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
type sshHostCertRequest struct {
|
||||
PublicKey string `json:"publicKey"`
|
||||
Principals []string `json:"principals"`
|
||||
KeyID string `json:"keyID"`
|
||||
}
|
||||
|
||||
type sshHostCertResponse struct {
|
||||
Cert string `json:"cert,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (b *broker) serveHost() {
|
||||
if b.cfg.hostAddr == "" {
|
||||
log.Printf("host plane disabled (set BROKER_HOST_ADDR + BROKER_HOST_CERT/KEY to enable SSH host-cert issuance)")
|
||||
return
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /ssh/hostcert", b.handleSSHHostCert)
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: b.cfg.hostAddr,
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 40 * time.Second, // signing blocks on step-ca
|
||||
IdleTimeout: 30 * time.Second,
|
||||
MaxHeaderBytes: 16 << 10,
|
||||
}
|
||||
|
||||
if b.cfg.hostInsecure {
|
||||
log.Printf("WARNING: BROKER_HOST_INSECURE=true — SSH host plane is PLAINTEXT with NO client-cert auth. DEV ONLY.")
|
||||
log.Fatalf("host listener: %v", srv.ListenAndServe())
|
||||
return
|
||||
}
|
||||
if b.cfg.hostCert == "" || b.cfg.hostKey == "" {
|
||||
log.Fatalf("host mTLS requires BROKER_HOST_CERT and BROKER_HOST_KEY (or BROKER_HOST_INSECURE=true for dev)")
|
||||
}
|
||||
clientCAPath := b.cfg.hostClientCA
|
||||
if clientCAPath == "" {
|
||||
clientCAPath = b.cfg.caRoot // default: the fleet root that signs host identities
|
||||
}
|
||||
cert, err := tls.LoadX509KeyPair(b.cfg.hostCert, b.cfg.hostKey)
|
||||
if err != nil {
|
||||
log.Fatalf("host server cert: %v", err)
|
||||
}
|
||||
caPEM, err := os.ReadFile(clientCAPath)
|
||||
if err != nil {
|
||||
log.Fatalf("host client CA: %v", err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(caPEM) {
|
||||
log.Fatalf("host client CA %s contains no certificates", clientCAPath)
|
||||
}
|
||||
srv.TLSConfig = &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
ClientAuth: tls.RequireAndVerifyClientCert, // must be an enrolled fleet host
|
||||
ClientCAs: pool,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
log.Printf("host plane up: %s (mTLS, fleet-CA host client certs) ssh-signer=%s", b.cfg.hostAddr, b.sshSigner.Kind())
|
||||
log.Fatalf("host listener: %v", srv.ListenAndServeTLS("", ""))
|
||||
}
|
||||
|
||||
func (b *broker) handleSSHHostCert(w http.ResponseWriter, r *http.Request) {
|
||||
// RequireAndVerifyClientCert already guaranteed the peer chains to the fleet
|
||||
// CA; the presented identity is who we're issuing for.
|
||||
peerCN := hostPeerCN(r)
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 16<<10)
|
||||
var req sshHostCertRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeSSHErr(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if req.KeyID == "" {
|
||||
req.KeyID = peerCN
|
||||
}
|
||||
if len(req.Principals) == 0 {
|
||||
writeSSHErr(w, http.StatusBadRequest, "no principals requested")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := ctxWithTimeout(35 * time.Second)
|
||||
defer cancel()
|
||||
certLine, err := b.sshSigner.SignHost(ctx, req.PublicKey, req.KeyID, req.Principals)
|
||||
if err != nil {
|
||||
log.Printf("audit ssh-hostcert-FAILED cn=%q principals=%v err=%v", peerCN, req.Principals, err)
|
||||
writeSSHErr(w, http.StatusBadGateway, "signing failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("audit SSH-HOSTCERT cn=%q keyid=%q principals=%v", peerCN, req.KeyID, req.Principals)
|
||||
writeJSON(w, http.StatusOK, sshHostCertResponse{Cert: certLine})
|
||||
}
|
||||
|
||||
func hostPeerCN(r *http.Request) string {
|
||||
if r.TLS != nil && len(r.TLS.PeerCertificates) > 0 {
|
||||
if cn := r.TLS.PeerCertificates[0].Subject.CommonName; cn != "" {
|
||||
return cn
|
||||
}
|
||||
return "unknown-host"
|
||||
}
|
||||
return "dev-insecure"
|
||||
}
|
||||
|
||||
func writeSSHErr(w http.ResponseWriter, code int, msg string) {
|
||||
writeJSON(w, code, sshHostCertResponse{Error: msg})
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newTestBroker() *broker {
|
||||
return &broker{cfg: loadConfig(), sshSigner: mockSSHSigner{}}
|
||||
}
|
||||
|
||||
func TestHandleSSHHostCert_OK(t *testing.T) {
|
||||
b := newTestBroker()
|
||||
body, _ := json.Marshal(sshHostCertRequest{
|
||||
PublicKey: "ecdsa-sha2-nistp256 AAAAB3Nz root@fleet",
|
||||
Principals: []string{"host01", "host01.fleet.lab"},
|
||||
KeyID: "host01.fleet.lab",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/ssh/hostcert", strings.NewReader(string(body)))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
b.handleSSHHostCert(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp sshHostCertResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(resp.Cert, "ecdsa-sha2-nistp256-cert-v01@openssh.com ") {
|
||||
t.Errorf("cert line = %q", resp.Cert)
|
||||
}
|
||||
if !strings.Contains(resp.Cert, "host01.fleet.lab") {
|
||||
t.Errorf("cert should carry keyID/principals: %q", resp.Cert)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSSHHostCert_NoPrincipals(t *testing.T) {
|
||||
b := newTestBroker()
|
||||
body, _ := json.Marshal(sshHostCertRequest{PublicKey: "ecdsa-sha2-nistp256 AAAA x", KeyID: "h"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/ssh/hostcert", strings.NewReader(string(body)))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
b.handleSSHHostCert(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("want 400 for missing principals, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSSHHostCert_BadJSON(t *testing.T) {
|
||||
b := newTestBroker()
|
||||
req := httptest.NewRequest(http.MethodPost, "/ssh/hostcert", strings.NewReader("{not json"))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
b.handleSSHHostCert(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("want 400 for bad JSON, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMockSSHSignerRejectsEmptyKey(t *testing.T) {
|
||||
if _, err := (mockSSHSigner{}).SignHost(context.Background(), "", "h", []string{"h"}); err == nil {
|
||||
t.Error("expected error on malformed public key")
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,13 @@ type config struct {
|
||||
adminKey string
|
||||
adminClientCA string
|
||||
adminInsecure bool
|
||||
// host plane (SSH host-cert issuance) mTLS
|
||||
hostAddr string
|
||||
hostCert string
|
||||
hostKey string
|
||||
hostClientCA string
|
||||
hostInsecure bool
|
||||
sshNotAfter string
|
||||
}
|
||||
|
||||
func loadConfig() config {
|
||||
@@ -87,14 +94,21 @@ func loadConfig() config {
|
||||
adminKey: env("BROKER_ADMIN_KEY", ""),
|
||||
adminClientCA: env("BROKER_ADMIN_CLIENT_CA", ""),
|
||||
adminInsecure: envBool("BROKER_ADMIN_INSECURE", false),
|
||||
hostAddr: env("BROKER_HOST_ADDR", ""), // empty => host plane disabled
|
||||
hostCert: env("BROKER_HOST_CERT", ""),
|
||||
hostKey: env("BROKER_HOST_KEY", ""),
|
||||
hostClientCA: env("BROKER_HOST_CLIENT_CA", ""), // default: caRoot (fleet root)
|
||||
hostInsecure: envBool("BROKER_HOST_INSECURE", false),
|
||||
sshNotAfter: env("STEP_SSH_CERT_NOT_AFTER", "720h"),
|
||||
}
|
||||
}
|
||||
|
||||
type broker struct {
|
||||
cfg config
|
||||
store *Store
|
||||
signer Signer
|
||||
rl *rateLimiter
|
||||
cfg config
|
||||
store *Store
|
||||
signer Signer
|
||||
sshSigner SSHSigner
|
||||
rl *rateLimiter
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -123,7 +137,20 @@ func main() {
|
||||
log.Fatalf("unknown BROKER_SIGNER %q (want step|mock)", cfg.signerKind)
|
||||
}
|
||||
|
||||
b := &broker{cfg: cfg, store: store, signer: signer, rl: newRateLimiter(cfg.ratePerMin)}
|
||||
// SSH host-cert signer (host plane) — same step|mock selection as the X.509
|
||||
// enrolment signer, so a mock broker exercises both without a running step-ca.
|
||||
var sshSigner SSHSigner
|
||||
switch cfg.signerKind {
|
||||
case "step":
|
||||
sshSigner = &stepSSHSigner{
|
||||
bin: cfg.stepBin, caURL: cfg.caURL, root: cfg.caRoot,
|
||||
provisioner: cfg.provisioner, passwordFile: cfg.passwordFile, notAfter: cfg.sshNotAfter,
|
||||
}
|
||||
case "mock":
|
||||
sshSigner = mockSSHSigner{}
|
||||
}
|
||||
|
||||
b := &broker{cfg: cfg, store: store, signer: signer, sshSigner: sshSigner, rl: newRateLimiter(cfg.ratePerMin)}
|
||||
|
||||
// background reaper: expire timed-out requests + evict old decided ones.
|
||||
go func() {
|
||||
@@ -138,11 +165,12 @@ func main() {
|
||||
}()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
wg.Add(3)
|
||||
go func() { defer wg.Done(); b.servePublic() }()
|
||||
go func() { defer wg.Done(); b.serveAdmin() }()
|
||||
log.Printf("enrol-broker up: public=%s admin=%s signer=%s words=%d ttl=%s",
|
||||
cfg.publicAddr, cfg.adminAddr, signer.Kind(), len(words), cfg.ttl)
|
||||
go func() { defer wg.Done(); b.serveHost() }() // SSH host-cert plane (no-op if BROKER_HOST_ADDR unset)
|
||||
log.Printf("enrol-broker up: public=%s admin=%s host=%s signer=%s words=%d ttl=%s",
|
||||
cfg.publicAddr, cfg.adminAddr, cfg.hostAddr, signer.Kind(), len(words), cfg.ttl)
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// sshsigner.go — issuing SSH HOST certificates for already-enrolled hosts.
|
||||
//
|
||||
// This is the SSH counterpart of signer.go. Unlike enrolment (unauthenticated,
|
||||
// human-approved), an SSH host cert is issued AUTOMATICALLY to a host that
|
||||
// authenticates on the host plane with its fleet-CA mTLS identity (host.go) —
|
||||
// the cert IS the authorisation. The broker holds the step-ca provisioner
|
||||
// credential and drives `step ssh certificate`, so step-ca stays behind the
|
||||
// single front door and the host never holds a CA secret. Closes F-02 / P-17.
|
||||
//
|
||||
// BENCH-VERIFY: the exact `step ssh certificate` sign flags vary across step
|
||||
// versions (and the CA must have its SSH host CA enabled — see
|
||||
// serving/dot1x-bench/step-ca/ssh-ca-bootstrap.sh). Confirm against your
|
||||
// `step version`. The mock path returns a synthetic (non-cryptographic) cert line
|
||||
// so the host-plane HTTP flow is testable without a running step-ca.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SSHSigner signs an OpenSSH host public key, returning the certificate as a
|
||||
// single authorized-keys/HostCertificate line.
|
||||
type SSHSigner interface {
|
||||
SignHost(ctx context.Context, pubKeyLine, keyID string, principals []string) (certLine string, err error)
|
||||
Kind() string
|
||||
}
|
||||
|
||||
// --- production: step-ca SSH host CA via the `step` CLI ---------------------
|
||||
|
||||
type stepSSHSigner struct {
|
||||
bin string
|
||||
caURL string
|
||||
root string
|
||||
provisioner string
|
||||
passwordFile string
|
||||
notAfter string
|
||||
}
|
||||
|
||||
func (s *stepSSHSigner) Kind() string { return "step" }
|
||||
|
||||
func (s *stepSSHSigner) SignHost(ctx context.Context, pubKeyLine, keyID string, principals []string) (string, error) {
|
||||
if strings.TrimSpace(pubKeyLine) == "" {
|
||||
return "", fmt.Errorf("empty public key")
|
||||
}
|
||||
if keyID == "" {
|
||||
return "", fmt.Errorf("empty key id")
|
||||
}
|
||||
if len(principals) == 0 {
|
||||
return "", fmt.Errorf("no principals requested")
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "sshhost-*")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
// `step ssh certificate --sign <id> <key.pub>` writes <key>-cert.pub next to
|
||||
// the input; give the input a .pub name so the derived cert path is stable.
|
||||
pubPath := filepath.Join(dir, "hostkey.pub")
|
||||
certPath := filepath.Join(dir, "hostkey-cert.pub")
|
||||
if err := os.WriteFile(pubPath, []byte(strings.TrimRight(pubKeyLine, "\n")+"\n"), 0o600); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"ssh", "certificate",
|
||||
"--host", // HOST certificate (not a user cert)
|
||||
"--sign", // sign the EXISTING public key (don't generate one)
|
||||
"--no-password", "--insecure", // non-interactive; key file is already on disk
|
||||
"--provisioner", s.provisioner,
|
||||
"--provisioner-password-file", s.passwordFile,
|
||||
"--ca-url", s.caURL,
|
||||
"--root", s.root,
|
||||
}
|
||||
for _, p := range principals {
|
||||
args = append(args, "--principal", p)
|
||||
}
|
||||
if s.notAfter != "" {
|
||||
args = append(args, "--not-after", s.notAfter)
|
||||
}
|
||||
args = append(args, keyID, pubPath)
|
||||
|
||||
if _, err := runStep(ctx, s.bin, args...); err != nil {
|
||||
return "", fmt.Errorf("step ssh certificate: %w", err)
|
||||
}
|
||||
cert, err := os.ReadFile(certPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read signed cert: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(string(cert)), nil
|
||||
}
|
||||
|
||||
// runStep executes `step ...`, returning stdout (stderr folded into the error).
|
||||
// We never log argv (it can carry a token) or the password-file contents.
|
||||
func runStep(ctx context.Context, bin string, args ...string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, bin, args...)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
var ee *exec.ExitError
|
||||
if errors.As(err, &ee) {
|
||||
return "", fmt.Errorf("%w: %s", err, strings.TrimSpace(string(ee.Stderr)))
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// --- dev only: synthetic cert line -----------------------------------------
|
||||
|
||||
// mockSSHSigner returns a deterministic, NON-cryptographic cert line so the
|
||||
// host-plane HTTP path is testable without a running step-ca. LAB/TEST ONLY.
|
||||
type mockSSHSigner struct{}
|
||||
|
||||
func (mockSSHSigner) Kind() string { return "mock" }
|
||||
|
||||
func (mockSSHSigner) SignHost(_ context.Context, pubKeyLine, keyID string, principals []string) (string, error) {
|
||||
fields := strings.Fields(pubKeyLine)
|
||||
if len(fields) < 2 {
|
||||
return "", fmt.Errorf("malformed public key")
|
||||
}
|
||||
// Not a real certificate — just an identifiable placeholder blob.
|
||||
return fmt.Sprintf("ecdsa-sha2-nistp256-cert-v01@openssh.com MOCK-%s-%s %s",
|
||||
keyID, strings.Join(principals, ","), fields[len(fields)-1]), nil
|
||||
}
|
||||
@@ -287,9 +287,9 @@ func fingerprint(pubDER, nonce []byte, words []string, n int) (string, error) {
|
||||
stream := digestStream(pubDER, nonce, (n*bpw+7)/8)
|
||||
out := make([]string, 0, n)
|
||||
bitpos := 0
|
||||
for i := 0; i < n; i++ {
|
||||
for range n {
|
||||
idx := 0
|
||||
for k := 0; k < bpw; k++ {
|
||||
for range bpw {
|
||||
byteIdx := bitpos / 8
|
||||
bit := 7 - uint(bitpos%8)
|
||||
b := (stream[byteIdx] >> bit) & 1
|
||||
|
||||
Reference in New Issue
Block a user